ugly-game 0.5.8 → 0.5.9

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.
@@ -52,9 +52,21 @@ export declare class GpuShadowRenderer {
52
52
  camVisible: number;
53
53
  lightVisible: number;
54
54
  };
55
+ private hizPipe;
56
+ private hizBGL;
57
+ private hizTex;
58
+ private hizView;
59
+ private hizParam;
60
+ private hizW;
61
+ private hizH;
62
+ private hizReady;
63
+ private occView;
55
64
  constructor(device: GPUDevice, format: GPUTextureFormat);
56
65
  private makeCullBuf;
66
+ private cullBG;
57
67
  private makeCullResources;
68
+ /** (Re)create the coarse Hi-Z for a `w×h` depth target and rebind the cull groups to it. Idempotent per size. */
69
+ private ensureHiZ;
58
70
  private scratch;
59
71
  private fill;
60
72
  private makeBindGroups;
@@ -65,10 +77,16 @@ export declare class GpuShadowRenderer {
65
77
  updateInstances(instances: CubeInstance[]): void;
66
78
  /** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
67
79
  updateOverlay(instances: CubeInstance[]): void;
68
- /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
80
+ /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
69
81
  private writeCull;
70
- /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
71
- frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean): void;
82
+ /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
83
+ * its size) to enable Hi-Z occlusion culling on the camera pass the renderer rebuilds a coarse Hi-Z from that
84
+ * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
85
+ frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean, occlusion?: {
86
+ depth: GPUTextureView;
87
+ w: number;
88
+ h: number;
89
+ }): void;
72
90
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
73
91
  * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
74
92
  readCullStats(): Promise<{
package/dist/gpuShadow.js CHANGED
@@ -77,14 +77,25 @@ fn hsv(hh: f32) -> vec3<f32> {
77
77
  // instance quaternion). This is exact for any size/aspect/rotation — critical because a bounding SPHERE from the
78
78
  // centre wrongly culls a big flat cube (a ground plane whose centre sits far behind the camera while the ground
79
79
  // underfoot is plainly in view). Mirrors gpuDriven.ts; validated against a CPU box-vs-plane oracle.
80
+ // OCCLUSION CULLING (Hi-Z). After the frustum test, an in-frustum cube is ALSO dropped if it's fully hidden behind
81
+ // nearer geometry. `hiz` is a coarse MAX-depth buffer (each texel = the farthest visible NDC depth over its block)
82
+ // built from the PREVIOUS frame's depth. A cube is occluded iff its nearest corner is behind the farthest occluder
83
+ // across its whole screen footprint. Using last frame's depth + a coarse buffer makes this HOLE-FREE by
84
+ // construction: a newly-disoccluded area has far/cleared depth so nothing there is culled — the worst case is
85
+ // under-culling for one frame, never a false cull. occ.x toggles it (camera pass only; the shadow pass never
86
+ // occludes — an off-screen occluder still casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
80
87
  const CULL_WGSL = /* wgsl */ `
81
88
  struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32> };
82
89
  struct Args { vc: u32, ic: atomic<u32>, fv: u32, fi: u32 };
83
- struct CullCam { planes: array<vec4<f32>, 6>, cnt: vec4<f32> }; // cnt.x = instance count
90
+ struct CullCam {
91
+ planes: array<vec4<f32>, 6>, cnt: vec4<f32>, // cnt.x = instance count
92
+ camVP: mat4x4<f32>, occ: vec4<f32>, // occ.x = occlusion on · occ.yz = hiz w,h · occ.w = downscale
93
+ };
84
94
  @group(0) @binding(0) var<storage, read> insts: array<Inst>;
85
95
  @group(0) @binding(1) var<storage, read_write> args: Args;
86
96
  @group(0) @binding(2) var<storage, read_write> visible: array<u32>;
87
97
  @group(0) @binding(3) var<uniform> cc: CullCam;
98
+ @group(0) @binding(4) var hiz: texture_2d<f32>;
88
99
  fn qr(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> { let t = 2.0 * cross(q.xyz, v); return v + q.w * t + cross(q.xyz, t); }
89
100
  @compute @workgroup_size(${CULL_WG})
90
101
  fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -101,10 +112,65 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
101
112
  let e = abs(dot(pl.xyz, bx)) + abs(dot(pl.xyz, by)) + abs(dot(pl.xyz, bz)); // box half-width along the normal
102
113
  if (dot(pl.xyz, c) + pl.w < -e) { return; }
103
114
  }
115
+ // ── Hi-Z occlusion (camera pass only) ──
116
+ if (cc.occ.x > 0.5) {
117
+ var mn = vec2<f32>(1e9, 1e9);
118
+ var mx = vec2<f32>(-1e9, -1e9);
119
+ var nearZ = 1e9;
120
+ var ok = true;
121
+ for (var ci = 0u; ci < 8u; ci++) {
122
+ let s = vec3<f32>(select(-1.0, 1.0, (ci & 1u) != 0u), select(-1.0, 1.0, (ci & 2u) != 0u), select(-1.0, 1.0, (ci & 4u) != 0u));
123
+ let corner = c + bx * s.x + by * s.y + bz * s.z;
124
+ let clip = cc.camVP * vec4<f32>(corner, 1.0);
125
+ if (clip.w <= 0.0) { ok = false; break; } // a corner at/behind the eye → don't occlusion-cull (keep)
126
+ let ndc = clip.xyz / clip.w;
127
+ let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5);
128
+ mn = min(mn, uv); mx = max(mx, uv);
129
+ nearZ = min(nearZ, ndc.z);
130
+ }
131
+ if (ok && nearZ >= 0.0) {
132
+ let dim = vec2<f32>(cc.occ.y, cc.occ.z);
133
+ let t0 = vec2<i32>(floor(clamp(mn, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
134
+ let t1 = vec2<i32>(floor(clamp(mx, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
135
+ // only query a bounded footprint; a huge on-screen box just isn't occlusion-tested (kept)
136
+ if (t1.x - t0.x <= 8 && t1.y - t0.y <= 8) {
137
+ var farOccluder = 0.0; // MAX over the footprint of the coarse max-depth
138
+ for (var y = t0.y; y <= t1.y; y++) {
139
+ for (var x = t0.x; x <= t1.x; x++) {
140
+ farOccluder = max(farOccluder, textureLoad(hiz, vec2<i32>(x, y), 0).r);
141
+ }
142
+ }
143
+ if (nearZ > farOccluder) { return; } // behind the farthest occluder everywhere → occluded
144
+ }
145
+ }
146
+ }
104
147
  let slot = atomicAdd(&args.ic, 1u);
105
148
  visible[slot] = i;
106
149
  }
107
150
  `;
151
+ // Hi-Z BUILD: max-reduce the previous frame's depth (a sampleable depth texture) into a coarse R32float buffer —
152
+ // each output texel = the FARTHEST (max) NDC depth over its `down`×`down` source block, so "cube behind this" is a
153
+ // safe occlusion test. depth24plus reads as a depth texture; textureLoad returns the stored [0,1] depth.
154
+ const HIZ_WGSL = /* wgsl */ `
155
+ @group(0) @binding(0) var srcDepth: texture_depth_2d;
156
+ @group(0) @binding(1) var dst: texture_storage_2d<r32float, write>;
157
+ struct P { dim: vec4<u32> }; // dim.xy = dst size, dim.zw = src size
158
+ @group(0) @binding(2) var<uniform> p: P;
159
+ @compute @workgroup_size(8, 8)
160
+ fn build(@builtin(global_invocation_id) gid: vec3<u32>) {
161
+ if (gid.x >= p.dim.x || gid.y >= p.dim.y) { return; }
162
+ let down = 8u;
163
+ var mx = 0.0;
164
+ for (var dy = 0u; dy < down; dy++) {
165
+ for (var dx = 0u; dx < down; dx++) {
166
+ let sx = gid.x * down + dx; let sy = gid.y * down + dy;
167
+ if (sx < p.dim.z && sy < p.dim.w) { mx = max(mx, textureLoad(srcDepth, vec2<i32>(i32(sx), i32(sy)), 0)); }
168
+ }
169
+ }
170
+ textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(mx, 0.0, 0.0, 1.0));
171
+ }
172
+ `;
173
+ const HIZ_DOWN = 8; // source pixels per Hi-Z texel (coarse ⇒ conservative, cheap)
108
174
  const SHADOW_WGSL = COMMON_WGSL + /* wgsl */ `
109
175
  @group(0) @binding(2) var<storage, read> vis: array<u32>;
110
176
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
@@ -232,6 +298,16 @@ export class GpuShadowRenderer {
232
298
  statsBuf; // readback: [camVisible, lightVisible] for the IR/tests
233
299
  /** Last frame's visible counts (main-pass camera cull, shadow-pass light cull) + total — for IR/telemetry. */
234
300
  lastCull = { total: 0, camVisible: 0, lightVisible: 0 };
301
+ // Hi-Z OCCLUSION: a coarse max-depth buffer rebuilt from each frame's depth, sampled by next frame's camera cull.
302
+ hizPipe;
303
+ hizBGL;
304
+ hizTex;
305
+ hizView;
306
+ hizParam;
307
+ hizW = 0;
308
+ hizH = 0;
309
+ hizReady = false; // a Hi-Z from a prior frame exists → safe to occlusion-test (false on frame 1 + resize)
310
+ occView; // the texture bound at cull binding 4 (a 1×1 dummy until occlusion is used)
235
311
  constructor(device, format) {
236
312
  this.device = device;
237
313
  this.camBuf = device.createBuffer({ size: this.cam.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
@@ -293,39 +369,71 @@ export class GpuShadowRenderer {
293
369
  { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // insts
294
370
  { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // args (atomic ic)
295
371
  { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // visible list
296
- { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count
372
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count + camVP + occ
373
+ { binding: 4, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'unfilterable-float' } }, // Hi-Z (dummy until occlusion is used)
297
374
  ] });
298
375
  this.cullPipe = device.createComputePipeline({
299
376
  layout: device.createPipelineLayout({ bindGroupLayouts: [this.cullBGL] }),
300
377
  compute: { module: cullMod, entryPoint: 'cull' },
301
378
  });
379
+ // Hi-Z occlusion build pipeline + a 1×1 dummy Hi-Z (bound when occlusion is off, never sampled)
380
+ const hizMod = device.createShaderModule({ code: HIZ_WGSL });
381
+ this.hizBGL = device.createBindGroupLayout({ entries: [
382
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth' } },
383
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, storageTexture: { access: 'write-only', format: 'r32float' } },
384
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
385
+ ] });
386
+ this.hizPipe = device.createComputePipeline({
387
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.hizBGL] }),
388
+ compute: { module: hizMod, entryPoint: 'build' },
389
+ });
390
+ this.hizParam = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
391
+ const dummy = device.createTexture({ size: [1, 1], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
392
+ this.occView = dummy.createView();
302
393
  }
303
394
  // One frustum's cull resources: indirect args (drawIndirect), a compacted visible-index list sized to the
304
- // instance capacity, and a small uniform holding the 6 planes + the live instance count.
395
+ // instance capacity, and a uniform holding the 6 planes + instance count + camVP + occlusion params.
305
396
  makeCullBuf() {
306
397
  const d = this.device;
307
398
  const args = d.createBuffer({ size: 4 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
308
399
  const vis = d.createBuffer({ size: Math.max(1, this.cap) * 4, usage: GPUBufferUsage.STORAGE });
309
- const cull = d.createBuffer({ size: 6 * 16 + 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
310
- const bg = d.createBindGroup({ layout: this.cullBGL, entries: [
400
+ const cull = d.createBuffer({ size: 6 * 16 + 16 + 64 + 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // planes+cnt+camVP+occ = 192
401
+ return { args, vis, cull };
402
+ }
403
+ cullBG(args, vis, cull) {
404
+ return this.device.createBindGroup({ layout: this.cullBGL, entries: [
311
405
  { binding: 0, resource: { buffer: this.instBuf } }, { binding: 1, resource: { buffer: args } },
312
- { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } }
406
+ { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } },
407
+ { binding: 4, resource: this.occView }
313
408
  ] });
314
- return { args, vis, cull, bg };
315
409
  }
316
410
  makeCullResources() {
317
411
  const cam = this.makeCullBuf();
318
412
  this.camArgs = cam.args;
319
413
  this.camVis = cam.vis;
320
414
  this.camCull = cam.cull;
321
- this.camCullBG = cam.bg;
322
415
  const lit = this.makeCullBuf();
323
416
  this.lightArgs = lit.args;
324
417
  this.lightVis = lit.vis;
325
418
  this.lightCull = lit.cull;
326
- this.lightCullBG = lit.bg;
419
+ this.camCullBG = this.cullBG(this.camArgs, this.camVis, this.camCull);
420
+ this.lightCullBG = this.cullBG(this.lightArgs, this.lightVis, this.lightCull);
327
421
  this.statsBuf = this.device.createBuffer({ size: 4 * 4 * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
328
422
  }
423
+ /** (Re)create the coarse Hi-Z for a `w×h` depth target and rebind the cull groups to it. Idempotent per size. */
424
+ ensureHiZ(w, h) {
425
+ const hw = Math.max(1, Math.ceil(w / HIZ_DOWN)), hh = Math.max(1, Math.ceil(h / HIZ_DOWN));
426
+ if (this.hizTex && this.hizW === hw && this.hizH === hh)
427
+ return;
428
+ this.hizW = hw;
429
+ this.hizH = hh;
430
+ this.hizReady = false; // fresh texture is uninitialized → skip occlusion until it's built once
431
+ this.hizTex = this.device.createTexture({ size: [hw, hh], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
432
+ this.hizView = this.hizTex.createView();
433
+ this.occView = this.hizView; // the cull now samples the real Hi-Z
434
+ this.camCullBG = this.cullBG(this.camArgs, this.camVis, this.camCull);
435
+ this.lightCullBG = this.cullBG(this.lightArgs, this.lightVis, this.lightCull);
436
+ }
329
437
  scratch = new Float32Array(0);
330
438
  fill(scratch, instances, n) {
331
439
  for (let i = 0; i < n; i++) {
@@ -388,10 +496,10 @@ export class GpuShadowRenderer {
388
496
  this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * 12);
389
497
  this.overlayCount = n;
390
498
  }
391
- /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
392
- writeCull(buf, vp) {
499
+ /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
500
+ writeCull(buf, vp, occlude) {
393
501
  const planes = frustumPlanes(vp);
394
- const u = new Float32Array(6 * 4 + 4);
502
+ const u = new Float32Array(48); // planes[24] + cnt[4] + camVP[16] + occ[4]
395
503
  for (let p = 0; p < 6; p++) {
396
504
  u[p * 4] = planes[p][0];
397
505
  u[p * 4 + 1] = planes[p][1];
@@ -399,10 +507,17 @@ export class GpuShadowRenderer {
399
507
  u[p * 4 + 3] = planes[p][3];
400
508
  }
401
509
  u[24] = this.count;
510
+ u.set(vp, 28); // camVP
511
+ u[44] = occlude ? 1 : 0;
512
+ u[45] = this.hizW;
513
+ u[46] = this.hizH;
514
+ u[47] = HIZ_DOWN;
402
515
  this.device.queue.writeBuffer(buf, 0, u);
403
516
  }
404
- /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
405
- frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false) {
517
+ /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
518
+ * its size) to enable Hi-Z occlusion culling on the camera pass — the renderer rebuilds a coarse Hi-Z from that
519
+ * 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) {
406
521
  this.cam.set(camVP, 0);
407
522
  this.cam.set(lightVP, 16);
408
523
  this.cam[32] = sunDir[0];
@@ -414,14 +529,17 @@ export class GpuShadowRenderer {
414
529
  this.cam[38] = sunCol[2];
415
530
  this.cam[39] = SHADOW_SIZE;
416
531
  this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
532
+ if (occlusion)
533
+ this.ensureHiZ(occlusion.w, occlusion.h);
534
+ const occludeNow = !!occlusion && this.hizReady; // only once a Hi-Z from a prior frame exists (never on frame 1/resize)
417
535
  // GPU-DRIVEN CULL: reset each frustum's indirect args ([vertexCount=36, instanceCount=0, 0, 0]), then compute-cull
418
- // — main pass vs the camera frustum, shadow pass vs the light frustum. Each dispatch atomically compacts its
419
- // survivors into a visible list + counts them in instanceCount, which the matching drawIndirect then consumes.
536
+ // — main pass vs the camera frustum (+ Hi-Z occlusion), shadow pass vs the light frustum. Each dispatch atomically
537
+ // compacts its survivors into a visible list + counts them in instanceCount, which the matching drawIndirect consumes.
420
538
  const resetArgs = new Uint32Array([36, 0, 0, 0]);
421
539
  this.device.queue.writeBuffer(this.camArgs, 0, resetArgs);
422
540
  this.device.queue.writeBuffer(this.lightArgs, 0, resetArgs);
423
- this.writeCull(this.camCull, camVP);
424
- this.writeCull(this.lightCull, lightVP);
541
+ this.writeCull(this.camCull, camVP, occludeNow);
542
+ this.writeCull(this.lightCull, lightVP, false);
425
543
  if (this.count > 0) {
426
544
  const cp = enc.beginComputePass();
427
545
  cp.setPipeline(this.cullPipe);
@@ -439,7 +557,7 @@ export class GpuShadowRenderer {
439
557
  sp.setBindGroup(0, this.shadowBG);
440
558
  sp.drawIndirect(this.lightArgs, 0);
441
559
  sp.end();
442
- // Pass 2: camera, shadow-sampled (camera-frustum-culled).
560
+ // Pass 2: camera, shadow-sampled (camera-frustum + Hi-Z-occlusion-culled).
443
561
  const mp = enc.beginRenderPass({
444
562
  colorAttachments: [{ view: colorView, ...(load ? { loadOp: 'load' } : { clearValue: { r: 0.04, g: 0.05, b: 0.07, a: 1 }, loadOp: 'clear' }), storeOp: 'store' }],
445
563
  depthStencilAttachment: { view: sceneDepth, ...(load ? { depthLoadOp: 'load' } : { depthClearValue: 1.0, depthLoadOp: 'clear' }), depthStoreOp: 'store' }
@@ -453,6 +571,19 @@ export class GpuShadowRenderer {
453
571
  mp.draw(36, this.overlayCount);
454
572
  } // translucent overlay last (uncapped, no cull)
455
573
  mp.end();
574
+ // Rebuild the coarse Hi-Z from the depth just written → next frame's cull uses it.
575
+ if (occlusion) {
576
+ this.device.queue.writeBuffer(this.hizParam, 0, new Uint32Array([this.hizW, this.hizH, occlusion.w, occlusion.h]));
577
+ const bg = this.device.createBindGroup({ layout: this.hizBGL, entries: [
578
+ { binding: 0, resource: occlusion.depth }, { binding: 1, resource: this.hizView }, { binding: 2, resource: { buffer: this.hizParam } }
579
+ ] });
580
+ const hp = enc.beginComputePass();
581
+ hp.setPipeline(this.hizPipe);
582
+ hp.setBindGroup(0, bg);
583
+ hp.dispatchWorkgroups(Math.ceil(this.hizW / 8), Math.ceil(this.hizH / 8));
584
+ hp.end();
585
+ this.hizReady = true;
586
+ }
456
587
  this.lastCull.total = this.count;
457
588
  }
458
589
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {