Skip to content

Commit 427a8af

Browse files
authored
feat(voxel-renderer): allow to customize the material properties (#204)
1 parent e5b2327 commit 427a8af

File tree

7 files changed

+97
-69
lines changed

7 files changed

+97
-69
lines changed

.changeset/old-cloths-visit.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+
Allow to customize the material in VoxelRenderer options

packages/voxel-renderer/docs/Tileset.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ Throws if no tileset is loaded or the referenced ID is unknown.
8181

8282
Returns the shared texture for a tileset. Defaults to `defaultTilesetId`.
8383

84-
#### `createMaterial(tilesetId?: string): THREE.MeshLambertMaterial`
85-
86-
Lazily creates and caches a `MeshLambertMaterial` backed by the tileset texture.
87-
8884
#### `getDefinitions(): Array<TilesetDefinition & { cols: number; rows: number }>`
8985

9086
Returns all registered tileset definitions with `cols` and `rows` resolved from the image.

packages/voxel-renderer/docs/VoxelRenderer.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,24 @@ Each chunk is rebuilt only when its content changes, keeping GPU work proportion
88
## VoxelRendererOptions
99

1010
```ts
11+
type MaterialCustomizerFn = (
12+
material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial,
13+
tilesetId: string
14+
) => void;
15+
1116
interface VoxelRendererOptions {
1217
/** Side length of each chunk in voxels. Default: `16`. */
1318
chunkSize?: number;
19+
1420
/** Chunk material preset. `"standard"` enables PBR at higher GPU cost. Default: `"lambert"`. */
1521
material?: "lambert" | "standard";
22+
23+
/**
24+
* Optional callback to customize each material after it is created.
25+
* Called with the material instance and the tileset ID it corresponds to
26+
*/
27+
materialCustomizer?: MaterialCustomizerFn;
28+
1629
/**
1730
* Fragments with alpha below this value are discarded.
1831
* Set `0` to disable cutout transparency. Default: `0.1`.

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ const { world } = runtime;
2626

2727
// ── Scene ─────────────────────────────────────────────────────────────────────
2828
const scene = world.sceneManager.getSource();
29-
scene.background = new THREE.Color("#87ceeb");
29+
scene.background = new THREE.Color("#211331");
3030

31-
const dirLight = new THREE.DirectionalLight(new THREE.Color("#ffffff"), 1.5);
31+
const dirLight = new THREE.DirectionalLight(new THREE.Color("#f6faff"), 2);
3232
dirLight.position.set(20, 40, 30);
3333
scene.add(
34-
new THREE.AmbientLight(new THREE.Color("#ffffff"), 1.5),
34+
new THREE.AmbientLight(new THREE.Color("#ffffff"), 2.5),
3535
dirLight
3636
);
3737

@@ -48,7 +48,13 @@ world.createActor("camera")
4848
// No blocks or layers supplied here — load() will register them from the JSON.
4949
world.createActor("map")
5050
.addComponent(VoxelRenderer, {
51-
material: "lambert"
51+
material: "lambert",
52+
materialCustomizer: (material) => {
53+
if (material instanceof THREE.MeshStandardMaterial) {
54+
material.metalness = 0;
55+
material.roughness = 0.85;
56+
}
57+
}
5258
})
5359
.addComponent(VoxelBehavior);
5460

packages/voxel-renderer/src/VoxelRenderer.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ import type { VoxelEntry, VoxelCoord } from "./world/types.ts";
4343
import { packTransform, type FACE } from "./utils/math.ts";
4444
import { FACE_OFFSETS } from "./mesh/math.ts";
4545

46+
type MaterialCustomizerFn = (
47+
material: THREE.MeshLambertMaterial | THREE.MeshStandardMaterial,
48+
tilesetId: string
49+
) => void;
50+
4651
export const VoxelRotation = {
4752
/** No rotation (default). */
4853
None: 0,
@@ -91,6 +96,13 @@ export interface VoxelRendererOptions {
9196
* is faster but only supports a simple diffuse map.
9297
*/
9398
material?: "lambert" | "standard";
99+
100+
/**
101+
* Optional callback to customize each material after it is created.
102+
* Called with the material instance and the tileset ID it corresponds to
103+
*/
104+
materialCustomizer?: MaterialCustomizerFn;
105+
94106
/**
95107
* Optional list of layer names to create on initialization.
96108
*/
@@ -145,6 +157,7 @@ export class VoxelRenderer extends ActorComponent {
145157
* One material per tileset ID. Created lazily; disposed on tileset reload or destroy.
146158
*/
147159
#materials = new Map<string, THREE.MeshLambertMaterial | THREE.MeshStandardMaterial>();
160+
#materialCustomizer?: MaterialCustomizerFn;
148161
#materialType: "lambert" | "standard";
149162
#alphaTest: number;
150163

@@ -160,6 +173,7 @@ export class VoxelRenderer extends ActorComponent {
160173
const {
161174
chunkSize = 16,
162175
material = "lambert",
176+
materialCustomizer,
163177
layers = [],
164178
rapier,
165179
blocks = [],
@@ -168,6 +182,7 @@ export class VoxelRenderer extends ActorComponent {
168182
} = options;
169183

170184
this.#materialType = material;
185+
this.#materialCustomizer = materialCustomizer;
171186
this.#alphaTest = alphaTest;
172187

173188
this.world = new VoxelWorld(chunkSize);
@@ -422,7 +437,9 @@ export class VoxelRenderer extends ActorComponent {
422437
return material;
423438
}
424439

425-
const texture = this.tilesetManager.getTexture(tilesetId) ?? null;
440+
const texture = this.tilesetManager.getTexture(
441+
tilesetId
442+
) ?? null;
426443

427444
if (this.#materialType === "standard") {
428445
material = new THREE.MeshStandardMaterial({
@@ -438,6 +455,7 @@ export class VoxelRenderer extends ActorComponent {
438455
alphaTest: this.#alphaTest
439456
});
440457
}
458+
this.#materialCustomizer?.(material, tilesetId);
441459

442460
this.#materials.set(tilesetId, material);
443461

packages/voxel-renderer/src/tileset/TilesetManager.ts

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ export interface TilesetEntry {
3535
/**
3636
* Manages tileset textures and computes UV regions for each tile.
3737
*
38-
* UV formula (Y-flipped for WebGL origin):
39-
* offsetU = col * tileW / imgW
40-
* offsetV = 1 - (row + 1) * tileH / imgH
41-
* scaleU = tileW / imgW
42-
* scaleV = tileH / imgH
38+
* UV formula (Y-flipped for WebGL origin, half-texel inset to prevent bleeding):
39+
* offsetU = col * tileW / imgW + 0.5 / imgW
40+
* offsetV = 1 - (row + 1) * tileH / imgH + 0.5 / imgH
41+
* scaleU = (tileW - 1) / imgW
42+
* scaleV = (tileH - 1) / imgH
4343
*
4444
* A single shared THREE.Texture is kept per tileset — no per-tile cloning.
4545
* NearestFilter is used to preserve pixel-art crispness.
@@ -112,11 +112,18 @@ export class TilesetManager {
112112
const imgW = cols * tileSize;
113113
const imgH = rows * tileSize;
114114

115+
// Inset by half a texel on each side so UV edge vertices sample the
116+
// centre of the first/last texel rather than the boundary between tiles.
117+
// This prevents floating-point interpolation from bleeding into adjacent
118+
// tiles in the atlas (the "white line between blocks" artifact).
119+
const halfTexelU = 0.5 / imgW;
120+
const halfTexelV = 0.5 / imgH;
121+
115122
return {
116-
offsetU: ref.col * tileSize / imgW,
117-
offsetV: 1 - ((ref.row + 1) * tileSize / imgH),
118-
scaleU: tileSize / imgW,
119-
scaleV: tileSize / imgH
123+
offsetU: ref.col * tileSize / imgW + halfTexelU,
124+
offsetV: 1 - ((ref.row + 1) * tileSize / imgH) + halfTexelV,
125+
scaleU: (tileSize - 1) / imgW,
126+
scaleV: (tileSize - 1) / imgH
120127
};
121128
}
122129

@@ -133,34 +140,6 @@ export class TilesetManager {
133140
undefined;
134141
}
135142

136-
/**
137-
* Returns (or lazily creates) a MeshLambertMaterial using the tileset texture.
138-
* The material is cached per-tileset to avoid redundant GPU uploads.
139-
*/
140-
createMaterial(
141-
tilesetId?: string
142-
): THREE.MeshLambertMaterial {
143-
const id = tilesetId ?? this.#defaultTilesetId;
144-
if (id === null) {
145-
throw new Error("TilesetManager: no tilesets have been loaded.");
146-
}
147-
148-
const entry = this.#tilesets.get(id);
149-
if (!entry) {
150-
throw new Error(`TilesetManager: tileset "${id}" is not loaded.`);
151-
}
152-
153-
if (!entry.material) {
154-
entry.material = new THREE.MeshLambertMaterial({
155-
map: entry.texture,
156-
side: THREE.FrontSide,
157-
alphaTest: 0.1
158-
});
159-
}
160-
161-
return entry.material;
162-
}
163-
164143
getDefinitions(): ResolvedTilesetDefinition[] {
165144
return [
166145
...this.#tilesets.values()

packages/voxel-renderer/test/tileset/TilesetManager.spec.ts

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -104,48 +104,59 @@ describe("TilesetManager.getTileUV", () => {
104104
const manager = new TilesetManager();
105105
manager.registerTexture(makeDef("terrain", 16, 4, 4), mockTexture(64, 64));
106106

107-
it("tile (col=0, row=0): offsetU=0, offsetV=0.75, scaleU=0.25, scaleV=0.25", () => {
108-
// offsetV = 1 - (0+1)/4 = 0.75
107+
it("tile (col=0, row=0): offsetU/offsetV/scale (inset by half-texel)", () => {
108+
// halfTexel = 0.5 / (cols*tileSize) = 0.5 / 64 = 0.0078125
109+
// offsetU = 0 + halfTexel = 0.0078125
110+
// offsetV = 1 - (0+1)/4 + halfTexel = 0.7578125
111+
// scaleU = scaleV = (tileSize - 1) / imgW = 15/64 = 0.234375
109112
const uv = manager.getTileUV({ col: 0, row: 0 });
110-
assert.ok(approxEqual(uv.offsetU, 0));
111-
assert.ok(approxEqual(uv.offsetV, 0.75));
112-
assert.ok(approxEqual(uv.scaleU, 0.25));
113-
assert.ok(approxEqual(uv.scaleV, 0.25));
113+
assert.ok(approxEqual(uv.offsetU, 0.0078125));
114+
assert.ok(approxEqual(uv.offsetV, 0.7578125));
115+
assert.ok(approxEqual(uv.scaleU, 15 / 64));
116+
assert.ok(approxEqual(uv.scaleV, 15 / 64));
114117
});
115118

116-
it("tile (col=1, row=0): offsetU=0.25", () => {
119+
it("tile (col=1, row=0): offsetU/offsetV (inset by half-texel)", () => {
120+
// offsetU = 1*16/64 + halfTexel = 0.2578125
121+
// offsetV = 0.7578125
117122
const uv = manager.getTileUV({ col: 1, row: 0 });
118-
assert.ok(approxEqual(uv.offsetU, 0.25));
119-
assert.ok(approxEqual(uv.offsetV, 0.75));
123+
assert.ok(approxEqual(uv.offsetU, 0.2578125));
124+
assert.ok(approxEqual(uv.offsetV, 0.7578125));
120125
});
121126

122-
it("tile (col=0, row=3) is the bottom row: offsetV=0", () => {
123-
// offsetV = 1 - (3+1)/4 = 0
127+
it("tile (col=0, row=3) is the bottom row: offsetV (inset by half-texel)", () => {
128+
// offsetV = 1 - (3+1)/4 + halfTexel = 0.0078125
124129
const uv = manager.getTileUV({ col: 0, row: 3 });
125-
assert.ok(approxEqual(uv.offsetV, 0));
130+
assert.ok(approxEqual(uv.offsetV, 0.0078125));
126131
});
127132

128-
it("tile (col=3, row=3): offsetU=0.75, offsetV=0", () => {
133+
it("tile (col=3, row=3): offsetU/offsetV (inset by half-texel)", () => {
134+
// offsetU = 3*16/64 + halfTexel = 0.7578125
135+
// offsetV = halfTexel = 0.0078125
129136
const uv = manager.getTileUV({ col: 3, row: 3 });
130-
assert.ok(approxEqual(uv.offsetU, 0.75));
131-
assert.ok(approxEqual(uv.offsetV, 0));
137+
assert.ok(approxEqual(uv.offsetU, 0.7578125));
138+
assert.ok(approxEqual(uv.offsetV, 0.0078125));
132139
});
133140
});
134141

135142
describe("UV computation — 2-col 2-row atlas (tileSize=16, image=32×32)", () => {
136143
const manager = new TilesetManager();
137144
manager.registerTexture(makeDef("small", 16, 2, 2), mockTexture(32, 32));
138145

139-
it("scaleU = scaleV = 0.5", () => {
146+
it("scaleU = scaleV (inset by half-texel)", () => {
147+
// cols=2, tileSize=16 => imgW=32, halfTexel=0.5/32=0.015625
148+
// scale = (16 - 1) / 32 = 15/32 = 0.46875
140149
const uv = manager.getTileUV({ col: 0, row: 0 });
141-
assert.ok(approxEqual(uv.scaleU, 0.5));
142-
assert.ok(approxEqual(uv.scaleV, 0.5));
150+
assert.ok(approxEqual(uv.scaleU, 15 / 32));
151+
assert.ok(approxEqual(uv.scaleV, 15 / 32));
143152
});
144153

145-
it("tile (col=1, row=1): offsetU=0.5, offsetV=0", () => {
154+
it("tile (col=1, row=1): offsetU/offsetV (inset by half-texel)", () => {
155+
// offsetU = 1*16/32 + halfTexel = 0.515625
156+
// offsetV = 1 - ((1+1)*16/32) + halfTexel = 0.015625
146157
const uv = manager.getTileUV({ col: 1, row: 1 });
147-
assert.ok(approxEqual(uv.offsetU, 0.5));
148-
assert.ok(approxEqual(uv.offsetV, 0));
158+
assert.ok(approxEqual(uv.offsetU, 0.515625));
159+
assert.ok(approxEqual(uv.offsetV, 0.015625));
149160
});
150161
});
151162

@@ -155,8 +166,8 @@ describe("TilesetManager.getTileUV", () => {
155166
manager.registerTexture(makeDef("walls", 16, 2, 2), mockTexture(32, 32));
156167

157168
const uv = manager.getTileUV({ col: 0, row: 0, tilesetId: "walls" });
158-
// walls is 2-col, 2-row → scaleU=0.5
159-
assert.ok(approxEqual(uv.scaleU, 0.5));
169+
// walls is 2-col, 2-row → scaleU=(tileSize-1)/(cols*tileSize)=15/32
170+
assert.ok(approxEqual(uv.scaleU, 15 / 32));
160171
});
161172
});
162173

0 commit comments

Comments
 (0)