Skip to content

Commit 3ac563e

Browse files
authored
feat(voxel-renderer): implement hook callback to catch layer events (#218)
1 parent 798c4be commit 3ac563e

File tree

8 files changed

+203
-23
lines changed

8 files changed

+203
-23
lines changed

.changeset/strict-otters-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/voxel.renderer": minor
3+
---
4+
5+
Implement hooks callback for layer event in VoxelRenderer class

packages/voxel-renderer/docs/VoxelRenderer.md

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,34 +14,67 @@ type MaterialCustomizerFn = (
1414
) => void;
1515

1616
interface VoxelRendererOptions {
17-
/** Side length of each chunk in voxels. Default: `16`. */
17+
/**
18+
* @default 16
19+
*/
1820
chunkSize?: number;
19-
20-
/** Chunk material preset. `"standard"` enables PBR at higher GPU cost. Default: `"lambert"`. */
21+
/**
22+
* Enables collision shapes when provided.
23+
* disabled by default to avoid forcing Rapier as a dependency for users who don't need physics.
24+
*/
25+
rapier?: {
26+
/** Rapier3D module (static API) */
27+
api: RapierAPI;
28+
/** Rapier3D world instance */
29+
world: RapierWorld;
30+
};
31+
/**
32+
* @default "lambert"
33+
* The type of material to use for rendering chunks. "standard" supports
34+
* roughness and metalness maps but is more expensive to render; "lambert"
35+
* is faster but only supports a simple diffuse map.
36+
*/
2137
material?: "lambert" | "standard";
2238

2339
/**
2440
* Optional callback to customize each material after it is created.
2541
* Called with the material instance and the tileset ID it corresponds to
2642
*/
2743
materialCustomizer?: MaterialCustomizerFn;
28-
44+
2945
/**
30-
* Fragments with alpha below this value are discarded.
31-
* Set `0` to disable cutout transparency. Default: `0.1`.
46+
* Optional list of layer names to create on initialization.
3247
*/
33-
alphaTest?: number;
34-
/** Layer names to create at initialisation. */
3548
layers?: string[];
36-
/** Block definitions to pre-register. */
49+
/**
50+
* Optional initial block definitions to register.
51+
* Block ID 0 is reserved for air
52+
*/
3753
blocks?: BlockDefinition[];
38-
/** Custom shapes added on top of the built-in registry. */
54+
/**
55+
* Optional block shapes to register in addition to the default
56+
* shapes provided by BlockShapeRegistry.createDefault().
57+
*/
3958
shapes?: BlockShape[];
40-
/** Enables Rapier3D collision. Omit to disable physics entirely. */
41-
rapier?: {
42-
api: RapierAPI;
43-
world: RapierWorld;
44-
};
59+
/**
60+
* Alpha value below which fragments are discarded (cutout transparency).
61+
* Set to 0 to disable alpha testing entirely (useful when your tileset tiles
62+
* have no transparency, or during debugging to confirm geometry is present).
63+
* @default 0.1
64+
*/
65+
alphaTest?: number;
66+
67+
/**
68+
* Optional logger instance for debug output.
69+
* Uses the engine's default logger if not provided.
70+
*/
71+
logger?: Systems.Logger;
72+
73+
/**
74+
* Optional callback that is called whenever a layer is added, removed, or updated.
75+
* Useful for synchronizing external systems with changes to the voxel world.
76+
*/
77+
onLayerUpdated?: VoxelLayerHookListener;
4578
}
4679
```
4780

@@ -123,6 +156,10 @@ interface VoxelLayerConfigurableOptions {
123156
}
124157
```
125158

159+
#### `updateLayer(name: string, options?: Partial< VoxelLayerConfigurableOptions >): boolean`
160+
161+
Update a layer that already exists. Return `false` if no layer is found with the given name and `true` when updated.
162+
126163
#### `removeLayer(name: string): VoxelLayer`
127164

128165
Remove and returns a boolean confirming layer deletion.
@@ -179,6 +216,27 @@ Serialises the full world state (layers, voxels, tileset metadata) to a plain JS
179216
Clears the current world, restores state from a JSON snapshot, and reloads any
180217
referenced tilesets that are not already loaded.
181218

219+
### Hooks
220+
221+
```ts
222+
export type VoxelLayerHookAction =
223+
| "added"
224+
| "removed"
225+
| "updated"
226+
| "offset-updated"
227+
| "voxel-set"
228+
| "voxel-removed";
229+
230+
export interface VoxelLayerHookEvent {
231+
layerName: string;
232+
action: VoxelLayerHookAction;
233+
metadata: Record<string, any>;
234+
}
235+
export type VoxelLayerHookListener = (
236+
event: VoxelLayerHookEvent
237+
) => void;
238+
```
239+
182240
---
183241

184242
## Example

packages/voxel-renderer/examples/scripts/components/VoxelMap.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export class VoxelBehavior extends ActorComponent {
1414
world = loadVoxelTiledMap("tilemap/brackeys-level.tmj", {
1515
layerMode: "stacked"
1616
});
17+
// @ts-ignore
18+
voxelRenderer: VoxelRenderer;
1719

1820
constructor(
1921
actor: Actor
@@ -31,8 +33,17 @@ export class VoxelBehavior extends ActorComponent {
3133
if (!voxelRenderer) {
3234
throw new Error("VoxelRenderer component not found on actor");
3335
}
36+
this.voxelRenderer = voxelRenderer;
3437
voxelRenderer
3538
.load(world)
3639
.catch(console.error);
3740
}
41+
42+
start() {
43+
setTimeout(() => {
44+
console.log("Toggling visibility of 'Ground' layer...");
45+
const success = this.voxelRenderer.removeLayer("Ground");
46+
console.log(`Layer visibility update ${success ? "succeeded" : "failed"}`);
47+
}, 5000);
48+
}
3849
}

packages/voxel-renderer/examples/scripts/demo-tiled.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const runtime = new Runtime(canvas, {
2323
});
2424

2525
const { world } = runtime;
26+
world.logger.setLevel("debug");
27+
world.logger.enableNamespace("*");
2628

2729
// ── Scene ─────────────────────────────────────────────────────────────────────
2830
const scene = world.sceneManager.getSource();

packages/voxel-renderer/src/VoxelRenderer.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ import { VoxelChunk } from "./world/VoxelChunk.ts";
4343
import type { VoxelEntry, VoxelCoord } from "./world/types.ts";
4444
import { packTransform, type FACE } from "./utils/math.ts";
4545
import { FACE_OFFSETS } from "./mesh/math.ts";
46+
import type {
47+
VoxelLayerHookListener
48+
} from "./hooks.ts";
4649

4750
type MaterialCustomizerFn = (
4851
material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial,
@@ -131,6 +134,12 @@ export interface VoxelRendererOptions {
131134
* Uses the engine's default logger if not provided.
132135
*/
133136
logger?: Systems.Logger;
137+
138+
/**
139+
* Optional callback that is called whenever a layer is added, removed, or updated.
140+
* Useful for synchronizing external systems with changes to the voxel world.
141+
*/
142+
onLayerUpdated?: VoxelLayerHookListener;
134143
}
135144

136145
/**
@@ -172,6 +181,7 @@ export class VoxelRenderer extends ActorComponent {
172181
#alphaTest: number;
173182

174183
#logger: Systems.Logger;
184+
#onLayerUpdated?: VoxelLayerHookListener;
175185

176186
constructor(
177187
actor: Actor<any>,
@@ -191,12 +201,14 @@ export class VoxelRenderer extends ActorComponent {
191201
blocks = [],
192202
shapes = [],
193203
alphaTest = 0.1,
194-
logger = actor.world.logger
204+
logger = actor.world.logger,
205+
onLayerUpdated
195206
} = options;
196207

197208
this.#materialType = material;
198209
this.#materialCustomizer = materialCustomizer;
199210
this.#alphaTest = alphaTest;
211+
this.#onLayerUpdated = onLayerUpdated;
200212
this.#logger = logger.child({
201213
namespace: "VoxelRenderer"
202214
});
@@ -304,13 +316,23 @@ export class VoxelRenderer extends ActorComponent {
304316
position,
305317
{ blockId, transform }
306318
);
319+
this.#onLayerUpdated?.({
320+
action: "voxel-set",
321+
layerName,
322+
metadata: { position, blockId, rotation, flipX, flipZ }
323+
});
307324
}
308325

309326
removeVoxel(
310327
layerName: string,
311328
options: VoxelRemoveOptions
312329
): void {
313330
this.world.removeVoxelAt(layerName, options.position);
331+
this.#onLayerUpdated?.({
332+
action: "voxel-removed",
333+
layerName,
334+
metadata: { position: options.position }
335+
});
314336
}
315337

316338
getVoxel(position: THREE.Vector3Like): VoxelEntry | undefined;
@@ -359,27 +381,69 @@ export class VoxelRenderer extends ActorComponent {
359381
name: string,
360382
options: VoxelLayerConfigurableOptions = {}
361383
): VoxelLayer {
362-
return this.world.addLayer(name, options);
384+
const layer = this.world.addLayer(name, options);
385+
this.#onLayerUpdated?.({
386+
action: "added",
387+
layerName: name,
388+
metadata: { options }
389+
});
390+
391+
return layer;
392+
}
393+
394+
updateLayer(
395+
name: string,
396+
options: Partial<VoxelLayerConfigurableOptions>
397+
): boolean {
398+
const result = this.world.updateLayer(name, options);
399+
if (result) {
400+
this.#onLayerUpdated?.({
401+
action: "updated",
402+
layerName: name,
403+
metadata: { options }
404+
});
405+
}
406+
407+
return result;
363408
}
364409

365410
removeLayer(
366411
name: string
367412
): boolean {
368-
return this.world.removeLayer(name);
413+
const result = this.world.removeLayer(name);
414+
if (result) {
415+
this.#onLayerUpdated?.({
416+
action: "removed",
417+
layerName: name,
418+
metadata: {}
419+
});
420+
}
421+
422+
return result;
369423
}
370424

371425
setLayerOffset(
372426
name: string,
373427
offset: VoxelCoord
374428
): void {
375429
this.world.setLayerOffset(name, offset);
430+
this.#onLayerUpdated?.({
431+
action: "offset-updated",
432+
layerName: name,
433+
metadata: { offset }
434+
});
376435
}
377436

378437
translateLayer(
379438
name: string,
380439
delta: VoxelCoord
381440
): void {
382441
this.world.translateLayer(name, delta);
442+
this.#onLayerUpdated?.({
443+
action: "offset-updated",
444+
layerName: name,
445+
metadata: { delta }
446+
});
383447
}
384448

385449
async loadTileset(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type VoxelLayerHookAction =
2+
| "added"
3+
| "removed"
4+
| "updated"
5+
| "offset-updated"
6+
| "voxel-set"
7+
| "voxel-removed";
8+
9+
export interface VoxelLayerHookEvent {
10+
layerName: string;
11+
action: VoxelLayerHookAction;
12+
metadata: Record<string, any>;
13+
}
14+
export type VoxelLayerHookListener = (
15+
event: VoxelLayerHookEvent
16+
) => void;

packages/voxel-renderer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
type VoxelSetOptions,
77
type VoxelRemoveOptions
88
} from "./VoxelRenderer.ts";
9+
export * from "./hooks.ts";
910

1011
// Built-in shapes
1112
export { Cube } from "./blocks/shapes/Cube.ts";

packages/voxel-renderer/src/world/VoxelWorld.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ export class VoxelWorld {
5151
return layer;
5252
}
5353

54+
updateLayer(
55+
name: string,
56+
options: Partial<VoxelLayerConfigurableOptions>
57+
): boolean {
58+
const layer = this.getLayer(name);
59+
if (!layer) {
60+
return false;
61+
}
62+
63+
if (options.properties) {
64+
layer.properties = structuredClone(options.properties);
65+
}
66+
if (options.visible !== undefined) {
67+
layer.visible = options.visible;
68+
}
69+
70+
return true;
71+
}
72+
5473
removeLayer(
5574
name: string
5675
): boolean {
@@ -242,14 +261,18 @@ export class VoxelWorld {
242261
this.#layers.sort((a, b) => b.order - a.order);
243262
}
244263

245-
#markAllLayersDirty(): void {
246-
for (const layer of this.#layers) {
247-
for (const chunk of layer.getChunks()) {
248-
chunk.dirty = true;
249-
}
264+
#markLayerDirty(
265+
layer: VoxelLayer
266+
): void {
267+
for (const chunk of layer.getChunks()) {
268+
chunk.dirty = true;
250269
}
251270
}
252271

272+
#markAllLayersDirty(): void {
273+
this.#layers.forEach((layer) => this.#markLayerDirty(layer));
274+
}
275+
253276
/**
254277
* When a voxel on a chunk boundary changes, the adjacent chunk also needs its
255278
* mesh rebuilt so boundary faces are culled correctly.

0 commit comments

Comments
 (0)