ugly-game 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ export interface PropMesh {
2
+ positions: Float32Array;
3
+ normals: Float32Array;
4
+ colors: Float32Array;
5
+ indices: Uint32Array;
6
+ }
7
+ export interface PropInstance {
8
+ model: number;
9
+ mat: Float32Array;
10
+ }
11
+ export declare class GpuPropsRenderer {
12
+ private device;
13
+ private pipe;
14
+ private occPipe;
15
+ private uBuf;
16
+ private lightBuf;
17
+ private models;
18
+ private instBufs;
19
+ private bgs;
20
+ private occBGs;
21
+ private counts;
22
+ constructor(device: GPUDevice, format: GPUTextureFormat);
23
+ setModels(meshes: PropMesh[]): void;
24
+ /** Group instances by model and upload per-model transform buffers. */
25
+ setInstances(instances: PropInstance[]): void;
26
+ /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
27
+ shadowPass(pass: GPURenderPassEncoder, lightVP: Float32Array): void;
28
+ frame(enc: GPUCommandEncoder, colorView: GPUTextureView, depthView: GPUTextureView, camVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], amb: [number, number, number], camPos: [number, number, number], fog: [number, number, number], fogDensity: number): void;
29
+ }
@@ -0,0 +1,147 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // GPU PROPS — instanced static-mesh renderer for scattered nature props (PLATFORM). Draws each cooked
3
+ // model (tree/rock/bush/…) many times at per-instance world transforms, lit + fogged to match the
4
+ // terrain. Runs in a load pass after the terrain so props depth-test against the surface.
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ const SHADER = /* wgsl */ `
7
+ struct U { mvp: mat4x4<f32>, sun: vec4<f32>, sunCol: vec4<f32>, amb: vec4<f32>, cam: vec4<f32>, fog: vec4<f32> };
8
+ @group(0) @binding(0) var<uniform> u: U;
9
+ @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
10
+ struct VO { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec3<f32> };
11
+ @vertex fn vs(@location(0) p: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) col: vec3<f32>, @builtin(instance_index) ii: u32) -> VO {
12
+ let m = models[ii];
13
+ let world = (m * vec4(p, 1.0)).xyz;
14
+ var o: VO; o.pos = u.mvp * vec4(world, 1.0); o.world = world; o.n = (m * vec4(nrm, 0.0)).xyz; o.c = col; return o;
15
+ }
16
+ @fragment fn fs(i: VO) -> @location(0) vec4<f32> {
17
+ let d = clamp(dot(normalize(i.n), -normalize(u.sun.xyz)), 0.0, 1.0);
18
+ let lit = i.c * (u.amb.xyz + d * u.sunCol.xyz);
19
+ let dist = length(i.world - u.cam.xyz);
20
+ let f = clamp(1.0 - exp(-dist * u.fog.w), 0.0, 1.0);
21
+ return vec4(mix(lit, u.fog.xyz, f), 1.0);
22
+ }`;
23
+ // Depth-only occluder pass: instanced prop depth from the sun's ortho view (so trees/rocks cast shadows).
24
+ const OCCLUDER = /* wgsl */ `
25
+ @group(0) @binding(0) var<uniform> lightVP: mat4x4<f32>;
26
+ @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
27
+ @vertex fn vs(@location(0) p: vec3<f32>, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
28
+ return lightVP * (models[ii] * vec4(p, 1.0));
29
+ }`;
30
+ export class GpuPropsRenderer {
31
+ device;
32
+ pipe;
33
+ occPipe;
34
+ uBuf;
35
+ lightBuf;
36
+ models = [];
37
+ instBufs = [];
38
+ bgs = [];
39
+ occBGs = [];
40
+ counts = [];
41
+ constructor(device, format) {
42
+ this.device = device;
43
+ const sm = device.createShaderModule({ code: SHADER });
44
+ this.pipe = device.createRenderPipeline({
45
+ layout: 'auto',
46
+ vertex: { module: sm, entryPoint: 'vs', buffers: [
47
+ { arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] },
48
+ { arrayStride: 12, attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }] },
49
+ { arrayStride: 12, attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x3' }] }
50
+ ] },
51
+ fragment: { module: sm, entryPoint: 'fs', targets: [{ format }] },
52
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
53
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
54
+ });
55
+ const om = device.createShaderModule({ code: OCCLUDER });
56
+ this.occPipe = device.createRenderPipeline({
57
+ layout: 'auto',
58
+ vertex: { module: om, entryPoint: 'vs', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }] },
59
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
60
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' },
61
+ });
62
+ this.uBuf = device.createBuffer({ size: 96 + 16 * 5, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
63
+ this.lightBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
64
+ }
65
+ setModels(meshes) {
66
+ const dev = this.device, U = GPUBufferUsage;
67
+ for (const p of this.models) {
68
+ p.posB.destroy();
69
+ p.nrmB.destroy();
70
+ p.colB.destroy();
71
+ p.idxB.destroy();
72
+ }
73
+ const mk = (d, u) => { const b = dev.createBuffer({ size: d.byteLength, usage: u }); dev.queue.writeBuffer(b, 0, d); return b; };
74
+ this.models = meshes.map((m) => ({ posB: mk(m.positions, U.VERTEX | U.COPY_DST), nrmB: mk(m.normals, U.VERTEX | U.COPY_DST), colB: mk(m.colors, U.VERTEX | U.COPY_DST), idxB: mk(m.indices, U.INDEX | U.COPY_DST), idxCount: m.indices.length }));
75
+ this.instBufs = meshes.map(() => null);
76
+ this.bgs = meshes.map(() => null);
77
+ this.occBGs = meshes.map(() => null);
78
+ this.counts = meshes.map(() => 0);
79
+ }
80
+ /** Group instances by model and upload per-model transform buffers. */
81
+ setInstances(instances) {
82
+ const dev = this.device;
83
+ const byModel = this.models.map(() => []);
84
+ const data = this.models.map(() => []);
85
+ for (const it of instances) {
86
+ if (it.model < 0 || it.model >= this.models.length)
87
+ continue;
88
+ byModel[it.model].push(0);
89
+ for (let i = 0; i < 16; i++)
90
+ data[it.model].push(it.mat[i]);
91
+ }
92
+ for (let m = 0; m < this.models.length; m++) {
93
+ this.instBufs[m]?.destroy();
94
+ const arr = new Float32Array(data[m]);
95
+ this.counts[m] = byModel[m].length;
96
+ if (arr.length === 0) {
97
+ this.instBufs[m] = null;
98
+ this.bgs[m] = null;
99
+ this.occBGs[m] = null;
100
+ continue;
101
+ }
102
+ const b = dev.createBuffer({ size: arr.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
103
+ dev.queue.writeBuffer(b, 0, arr);
104
+ this.instBufs[m] = b;
105
+ this.bgs[m] = dev.createBindGroup({ layout: this.pipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: this.uBuf } }, { binding: 1, resource: { buffer: b } }] });
106
+ this.occBGs[m] = dev.createBindGroup({ layout: this.occPipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: this.lightBuf } }, { binding: 1, resource: { buffer: b } }] });
107
+ }
108
+ }
109
+ /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
110
+ shadowPass(pass, lightVP) {
111
+ this.device.queue.writeBuffer(this.lightBuf, 0, lightVP);
112
+ pass.setPipeline(this.occPipe);
113
+ for (let m = 0; m < this.models.length; m++) {
114
+ const bg = this.occBGs[m], mdl = this.models[m];
115
+ if (!bg || this.counts[m] === 0)
116
+ continue;
117
+ pass.setBindGroup(0, bg);
118
+ pass.setVertexBuffer(0, mdl.posB);
119
+ pass.setIndexBuffer(mdl.idxB, 'uint32');
120
+ pass.drawIndexed(mdl.idxCount, this.counts[m]);
121
+ }
122
+ }
123
+ frame(enc, colorView, depthView, camVP, sunDir, sunCol, amb, camPos, fog, fogDensity) {
124
+ const u = new Float32Array(24 + 20);
125
+ u.set(camVP, 0);
126
+ u.set([sunDir[0], sunDir[1], sunDir[2], 0], 16);
127
+ u.set([sunCol[0], sunCol[1], sunCol[2], 0], 20);
128
+ u.set([amb[0], amb[1], amb[2], 0], 24);
129
+ u.set([camPos[0], camPos[1], camPos[2], 0], 28);
130
+ u.set([fog[0], fog[1], fog[2], fogDensity], 32);
131
+ this.device.queue.writeBuffer(this.uBuf, 0, u);
132
+ const rp = enc.beginRenderPass({ colorAttachments: [{ view: colorView, loadOp: 'load', storeOp: 'store' }], depthStencilAttachment: { view: depthView, depthLoadOp: 'load', depthStoreOp: 'store' } });
133
+ rp.setPipeline(this.pipe);
134
+ for (let m = 0; m < this.models.length; m++) {
135
+ const bg = this.bgs[m], mdl = this.models[m];
136
+ if (!bg || this.counts[m] === 0)
137
+ continue;
138
+ rp.setBindGroup(0, bg);
139
+ rp.setVertexBuffer(0, mdl.posB);
140
+ rp.setVertexBuffer(1, mdl.nrmB);
141
+ rp.setVertexBuffer(2, mdl.colB);
142
+ rp.setIndexBuffer(mdl.idxB, 'uint32');
143
+ rp.drawIndexed(mdl.idxCount, this.counts[m]);
144
+ }
145
+ rp.end();
146
+ }
147
+ }
@@ -8,6 +8,19 @@ export interface CubeInstance {
8
8
  hue: number;
9
9
  emissive?: number;
10
10
  rot?: [number, number, number, number];
11
+ tile?: number;
12
+ up?: number;
13
+ }
14
+ /** Atlas layout: which grid tiles a material's top/side/bottom faces sample. Grid indices into a cols×rows atlas. */
15
+ export interface AtlasLayout {
16
+ tileSize: number;
17
+ cols: number;
18
+ rows: number;
19
+ tiles: Record<number, {
20
+ top: number;
21
+ side: number;
22
+ bottom: number;
23
+ }>;
11
24
  }
12
25
  /** The 36-vertex cube as WGSL array-literal bodies (pos + per-face normal). Shared by shadow modules. */
13
26
  export declare const CUBE: {
@@ -29,6 +42,10 @@ export declare class GpuShadowRenderer {
29
42
  private shadowSampler;
30
43
  private count;
31
44
  private cam;
45
+ private atlasView;
46
+ private atlasSampler;
47
+ private tileRectBuf;
48
+ private useAtlas;
32
49
  private alphaPipe;
33
50
  private overlayBuf;
34
51
  private overlayBG;
@@ -70,6 +87,10 @@ export declare class GpuShadowRenderer {
70
87
  private scratch;
71
88
  private fill;
72
89
  private makeBindGroups;
90
+ /** Bind a block-texture atlas + its material→tile layout. Each material's top/side/bottom faces sample distinct
91
+ * atlas tiles; a `CubeInstance.tile` of that material id + its `up`-face code selects the right tile in-shader.
92
+ * Call once after the atlas texture is uploaded; instances with `tile < 0` still use the flat hue path. */
93
+ setAtlas(tex: GPUTexture, layout: AtlasLayout): void;
73
94
  /** Static scene: sizes the buffer to exactly these instances. */
74
95
  setScene(instances: CubeInstance[]): void;
75
96
  /** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
@@ -86,7 +107,7 @@ export declare class GpuShadowRenderer {
86
107
  depth: GPUTextureView;
87
108
  w: number;
88
109
  h: number;
89
- }): void;
110
+ }, tint?: [number, number, number, number], extraShadow?: (sp: GPURenderPassEncoder, lightVP: Float32Array) => void): void;
90
111
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
91
112
  * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
92
113
  readCullStats(): Promise<{
package/dist/gpuShadow.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { frustumPlanes } from './mat4';
15
15
  const SHADOW_SIZE = 2048;
16
16
  const CULL_WG = 64; // compute-cull workgroup size
17
+ const INST_F = 16; // floats per instance: pos.xyz+hue, scl.xyz+emissive, rot quat, aux (tile,up,_,_)
17
18
  const OVERLAY_CAP = 512; // max translucent overlay cubes per frame (the place ghost + headroom)
18
19
  // Build the 36-vertex cube (6 faces × 2 tris) in JS and inject as WGSL const arrays —
19
20
  // bulletproof + readable vs. bit-twiddling from vertex_index. cullMode is 'none', so winding
@@ -48,8 +49,10 @@ struct Cam {
48
49
  lightVP: mat4x4<f32>,
49
50
  sun: vec4<f32>, // xyz = direction light travels, w = ambient
50
51
  sunCol: vec4<f32>, // xyz = sun colour, w = shadow map size (texels)
52
+ tint: vec4<f32>, // rgb = per-planet tint · w = tint strength (0 = none)
53
+ atlas: vec4<f32>, // x = useAtlas (1/0)
51
54
  };
52
- struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32> }; // pos.w = hue (<0 ⇒ ground) · scl.w = emissive/alpha · rot = quaternion
55
+ struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32>, aux: vec4<f32> }; // pos.w = hue (<0 ⇒ ground) · scl.w = emissive/alpha · rot = quaternion · aux.x = atlas tile (<0 ⇒ hue) · aux.y = up-face code
53
56
  @group(0) @binding(0) var<uniform> cam: Cam;
54
57
  @group(0) @binding(1) var<storage, read> insts: array<Inst>;
55
58
 
@@ -85,7 +88,7 @@ fn hsv(hh: f32) -> vec3<f32> {
85
88
  // under-culling for one frame, never a false cull. occ.x toggles it (camera pass only; the shadow pass never
86
89
  // occludes — an off-screen occluder still casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
87
90
  const CULL_WGSL = /* wgsl */ `
88
- struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32> };
91
+ struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32>, aux: vec4<f32> };
89
92
  struct Args { vc: u32, ic: atomic<u32>, fv: u32, fi: u32 };
90
93
  struct CullCam {
91
94
  planes: array<vec4<f32>, 6>, cnt: vec4<f32>, // cnt.x = instance count
@@ -181,8 +184,18 @@ const MAIN_WGSL = COMMON_WGSL + /* wgsl */ `
181
184
  @group(0) @binding(2) var shadowMap: texture_depth_2d;
182
185
  @group(0) @binding(3) var shadowSamp: sampler_comparison;
183
186
  @group(0) @binding(4) var<storage, read> vis: array<u32>;
187
+ @group(0) @binding(5) var atlasTex: texture_2d<f32>;
188
+ @group(0) @binding(6) var atlasSamp: sampler;
189
+ @group(0) @binding(7) var<storage, read> tileRects: array<vec4<f32>>; // [u0,v0,du,dv] per (material*3 + face-variant)
184
190
 
185
- struct VOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) hue: f32, @location(3) emis: f32, @location(4) blk: vec3<f32>, @location(5) objN: vec3<f32> };
191
+ struct VOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) hue: f32, @location(3) emis: f32, @location(4) blk: vec3<f32>, @location(5) objN: vec3<f32>, @location(6) tile: f32, @location(7) upc: f32 };
192
+
193
+ fn faceVec(code: f32) -> vec3<f32> {
194
+ let c = i32(code);
195
+ if (c == 0) { return vec3(1.,0.,0.); } if (c == 1) { return vec3(-1.,0.,0.); }
196
+ if (c == 2) { return vec3(0.,1.,0.); } if (c == 3) { return vec3(0.,-1.,0.); }
197
+ if (c == 4) { return vec3(0.,0.,1.); } return vec3(0.,0.,-1.);
198
+ }
186
199
 
187
200
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VOut {
188
201
  let inst = insts[vis[ii]];
@@ -198,6 +211,7 @@ struct VOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>,
198
211
  // by inst.scl so a merged LOD cube (scl>1) still shows its sub-blocks. objN = pre-rotation face normal.
199
212
  o.blk = (CUBE_POS[vi] + vec3(0.5, 0.5, 0.5)) * inst.scl.xyz;
200
213
  o.objN = CUBE_NRM[vi];
214
+ o.tile = inst.aux.x; o.upc = inst.aux.y;
201
215
  return o;
202
216
  }
203
217
 
@@ -232,7 +246,6 @@ fn blockShade(fx: f32, fz: f32) -> f32 {
232
246
  }
233
247
  let N = normalize(i.nrm);
234
248
  let ndl = max(dot(N, -normalize(cam.sun.xyz)), 0.0);
235
- let base = select(mix(vec3(0.34), hsv(i.hue), 0.62), vec3(0.6, 0.62, 0.66), i.hue < 0.0);
236
249
  let sh = shadowFactor(i.world, ndl);
237
250
  // in-block coords = the two OBJECT axes not along the (object-space, axis-aligned) face normal — so the AO
238
251
  // pattern is anchored to the model's own block grid and translates/rotates with the instance.
@@ -241,7 +254,19 @@ fn blockShade(fx: f32, fz: f32) -> f32 {
241
254
  if (an.y >= an.x && an.y >= an.z) { fx = fract(i.blk.x); fz = fract(i.blk.z); }
242
255
  else if (an.x >= an.z) { fx = fract(i.blk.y); fz = fract(i.blk.z); }
243
256
  else { fx = fract(i.blk.x); fz = fract(i.blk.y); }
244
- let ao = blockShade(fx, fz);
257
+ var base = select(mix(vec3(0.34), hsv(i.hue), 0.62), vec3(0.6, 0.62, 0.66), i.hue < 0.0);
258
+ if (cam.atlas.x > 0.5 && i.tile >= 0.0) { // ATLAS: pick top/side/bottom tile from the face vs the up axis, tile the AO in-face coords
259
+ let d = dot(i.objN, faceVec(i.upc));
260
+ var variant = 1u; // side
261
+ if (d > 0.5) { variant = 0u; } else if (d < -0.5) { variant = 2u; }
262
+ let rect = tileRects[u32(i.tile) * 3u + variant];
263
+ let uv = rect.xy + fract(vec2(fx, fz)) * rect.zw;
264
+ let texc = textureSampleLevel(atlasTex, atlasSamp, uv, 0.0).rgb; // explicit LOD (early-return above makes control flow non-uniform)
265
+ base = mix(texc, texc * cam.tint.rgb, cam.tint.w); // per-planet tint
266
+ }
267
+ // per-block edge AO makes each voxel visible on FLAT-hue cubes, but reads as a hard GRID over textures — so the
268
+ // atlas path uses a soft, mostly-flat AO (texture already supplies surface detail) instead of the strong grid.
269
+ let ao = select(blockShade(fx, fz), mix(blockShade(fx, fz), 1.0, 0.8), cam.atlas.x > 0.5 && i.tile >= 0.0);
245
270
  let lit = base * (cam.sun.w + cam.sunCol.xyz * ndl * sh) * ao;
246
271
  return vec4(pow(clamp(lit, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0); // gamma
247
272
  }
@@ -277,11 +302,15 @@ export class GpuShadowRenderer {
277
302
  mainBGL;
278
303
  shadowSampler;
279
304
  count = 0;
280
- cam = new Float32Array(40); // 2×mat4 + 2×vec4
305
+ cam = new Float32Array(48); // 2×mat4 + sun + sunCol + tint + atlas
306
+ atlasView;
307
+ atlasSampler;
308
+ tileRectBuf;
309
+ useAtlas = 0;
281
310
  alphaPipe;
282
311
  overlayBuf;
283
312
  overlayBG;
284
- overlayScratch = new Float32Array(OVERLAY_CAP * 12);
313
+ overlayScratch = new Float32Array(OVERLAY_CAP * INST_F);
285
314
  overlayCount = 0;
286
315
  // GPU-driven culling: one compute pipeline; per-frustum (camera + light) indirect args + compacted visible lists.
287
316
  cullPipe;
@@ -332,6 +361,9 @@ export class GpuShadowRenderer {
332
361
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'depth' } },
333
362
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'comparison' } },
334
363
  { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, // camera-visible list
364
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, // block atlas
365
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // atlas sampler
366
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } }, // per-tile UV rects
335
367
  ] });
336
368
  this.mainPipe = device.createRenderPipeline({
337
369
  layout: device.createPipelineLayout({ bindGroupLayouts: [mainBGL] }),
@@ -390,6 +422,12 @@ export class GpuShadowRenderer {
390
422
  this.hizParam = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
391
423
  const dummy = device.createTexture({ size: [1, 1], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
392
424
  this.occView = dummy.createView();
425
+ // atlas dummies (bound until setAtlas): a 1×1 white texture + a 1-entry rect buffer + a plain sampler
426
+ const white = device.createTexture({ size: [1, 1], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
427
+ device.queue.writeTexture({ texture: white }, new Uint8Array([255, 255, 255, 255]), {}, [1, 1]);
428
+ this.atlasView = white.createView();
429
+ this.atlasSampler = device.createSampler({ magFilter: 'nearest', minFilter: 'linear', mipmapFilter: 'linear' });
430
+ this.tileRectBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
393
431
  }
394
432
  // One frustum's cull resources: indirect args (drawIndirect), a compacted visible-index list sized to the
395
433
  // instance capacity, and a uniform holding the 6 planes + instance count + camVP + occlusion params.
@@ -437,7 +475,7 @@ export class GpuShadowRenderer {
437
475
  scratch = new Float32Array(0);
438
476
  fill(scratch, instances, n) {
439
477
  for (let i = 0; i < n; i++) {
440
- const c = instances[i], o = i * 12, r = c.rot;
478
+ const c = instances[i], o = i * INST_F, r = c.rot;
441
479
  scratch[o] = c.x;
442
480
  scratch[o + 1] = c.y;
443
481
  scratch[o + 2] = c.z;
@@ -450,6 +488,10 @@ export class GpuShadowRenderer {
450
488
  scratch[o + 9] = r ? r[1] : 0;
451
489
  scratch[o + 10] = r ? r[2] : 0;
452
490
  scratch[o + 11] = r ? r[3] : 1; // quaternion, default identity
491
+ scratch[o + 12] = c.tile ?? -1;
492
+ scratch[o + 13] = c.up ?? 1;
493
+ scratch[o + 14] = 0;
494
+ scratch[o + 15] = 0; // aux: atlas tile id + up-face code
453
495
  }
454
496
  }
455
497
  makeBindGroups() {
@@ -462,13 +504,37 @@ export class GpuShadowRenderer {
462
504
  { binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
463
505
  { binding: 2, resource: this.shadowView }, { binding: 3, resource: this.shadowSampler },
464
506
  { binding: 4, resource: { buffer: this.camVis } },
507
+ { binding: 5, resource: this.atlasView }, { binding: 6, resource: this.atlasSampler }, { binding: 7, resource: { buffer: this.tileRectBuf } },
465
508
  ] });
466
509
  }
510
+ /** Bind a block-texture atlas + its material→tile layout. Each material's top/side/bottom faces sample distinct
511
+ * atlas tiles; a `CubeInstance.tile` of that material id + its `up`-face code selects the right tile in-shader.
512
+ * Call once after the atlas texture is uploaded; instances with `tile < 0` still use the flat hue path. */
513
+ setAtlas(tex, layout) {
514
+ this.atlasView = tex.createView();
515
+ const ids = Object.keys(layout.tiles).map(Number);
516
+ const maxMat = (ids.length ? Math.max(...ids) : 0) + 1;
517
+ const rects = new Float32Array(maxMat * 3 * 4);
518
+ const rectOf = (g) => {
519
+ const cx = g % layout.cols, cy = Math.floor(g / layout.cols);
520
+ return [cx / layout.cols, cy / layout.rows, 1 / layout.cols, 1 / layout.rows];
521
+ };
522
+ for (const [mat, f] of Object.entries(layout.tiles)) {
523
+ const b = Number(mat) * 3 * 4;
524
+ rects.set(rectOf(f.top), b);
525
+ rects.set(rectOf(f.side), b + 4);
526
+ rects.set(rectOf(f.bottom), b + 8);
527
+ }
528
+ this.tileRectBuf = this.device.createBuffer({ size: Math.max(16, rects.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
529
+ this.device.queue.writeBuffer(this.tileRectBuf, 0, rects);
530
+ this.useAtlas = 1;
531
+ this.makeBindGroups(); // rebind the main pass with the real atlas + rects
532
+ }
467
533
  /** Static scene: sizes the buffer to exactly these instances. */
468
534
  setScene(instances) {
469
535
  this.count = instances.length;
470
536
  this.cap = this.count;
471
- this.scratch = new Float32Array(this.count * 12);
537
+ this.scratch = new Float32Array(this.count * INST_F);
472
538
  this.fill(this.scratch, instances, this.count);
473
539
  this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
474
540
  this.device.queue.writeBuffer(this.instBuf, 0, this.scratch);
@@ -477,15 +543,15 @@ export class GpuShadowRenderer {
477
543
  /** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
478
544
  setCapacity(cap) {
479
545
  this.cap = cap;
480
- this.scratch = new Float32Array(cap * 12);
546
+ this.scratch = new Float32Array(cap * INST_F);
481
547
  this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
482
548
  this.count = 0;
483
549
  this.makeBindGroups();
484
550
  }
485
551
  updateInstances(instances) {
486
- const n = Math.min(instances.length, this.scratch.length / 12);
552
+ const n = Math.min(instances.length, this.scratch.length / INST_F);
487
553
  this.fill(this.scratch, instances, n);
488
- this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * 12);
554
+ this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * INST_F);
489
555
  this.count = n;
490
556
  }
491
557
  /** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
@@ -493,7 +559,7 @@ export class GpuShadowRenderer {
493
559
  const n = Math.min(instances.length, OVERLAY_CAP);
494
560
  this.fill(this.overlayScratch, instances, n);
495
561
  if (n > 0)
496
- this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * 12);
562
+ this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * INST_F);
497
563
  this.overlayCount = n;
498
564
  }
499
565
  /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
@@ -517,7 +583,7 @@ export class GpuShadowRenderer {
517
583
  /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
518
584
  * its size) to enable Hi-Z occlusion culling on the camera pass — the renderer rebuilds a coarse Hi-Z from that
519
585
  * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
520
- frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false, occlusion) {
586
+ frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false, occlusion, tint = [1, 1, 1, 0], extraShadow) {
521
587
  this.cam.set(camVP, 0);
522
588
  this.cam.set(lightVP, 16);
523
589
  this.cam[32] = sunDir[0];
@@ -528,6 +594,11 @@ export class GpuShadowRenderer {
528
594
  this.cam[37] = sunCol[1];
529
595
  this.cam[38] = sunCol[2];
530
596
  this.cam[39] = SHADOW_SIZE;
597
+ this.cam[40] = tint[0];
598
+ this.cam[41] = tint[1];
599
+ this.cam[42] = tint[2];
600
+ this.cam[43] = tint[3];
601
+ this.cam[44] = this.useAtlas;
531
602
  this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
532
603
  if (occlusion)
533
604
  this.ensureHiZ(occlusion.w, occlusion.h);
@@ -556,6 +627,8 @@ export class GpuShadowRenderer {
556
627
  sp.setPipeline(this.shadowPipe);
557
628
  sp.setBindGroup(0, this.shadowBG);
558
629
  sp.drawIndirect(this.lightArgs, 0);
630
+ if (extraShadow)
631
+ extraShadow(sp, lightVP); // let the app cast extra occluders (mesh props/trees) into the same sun shadow map
559
632
  sp.end();
560
633
  // Pass 2: camera, shadow-sampled (camera-frustum + Hi-Z-occlusion-culled).
561
634
  const mp = enc.beginRenderPass({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {