ugly-game 0.5.8 → 0.5.10

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;
@@ -52,12 +69,28 @@ export declare class GpuShadowRenderer {
52
69
  camVisible: number;
53
70
  lightVisible: number;
54
71
  };
72
+ private hizPipe;
73
+ private hizBGL;
74
+ private hizTex;
75
+ private hizView;
76
+ private hizParam;
77
+ private hizW;
78
+ private hizH;
79
+ private hizReady;
80
+ private occView;
55
81
  constructor(device: GPUDevice, format: GPUTextureFormat);
56
82
  private makeCullBuf;
83
+ private cullBG;
57
84
  private makeCullResources;
85
+ /** (Re)create the coarse Hi-Z for a `w×h` depth target and rebind the cull groups to it. Idempotent per size. */
86
+ private ensureHiZ;
58
87
  private scratch;
59
88
  private fill;
60
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;
61
94
  /** Static scene: sizes the buffer to exactly these instances. */
62
95
  setScene(instances: CubeInstance[]): void;
63
96
  /** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
@@ -65,10 +98,16 @@ export declare class GpuShadowRenderer {
65
98
  updateInstances(instances: CubeInstance[]): void;
66
99
  /** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
67
100
  updateOverlay(instances: CubeInstance[]): void;
68
- /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
101
+ /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
69
102
  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;
103
+ /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
104
+ * its size) to enable Hi-Z occlusion culling on the camera pass the renderer rebuilds a coarse Hi-Z from that
105
+ * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
106
+ frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean, occlusion?: {
107
+ depth: GPUTextureView;
108
+ w: number;
109
+ h: number;
110
+ }, tint?: [number, number, number, number], extraShadow?: (sp: GPURenderPassEncoder, lightVP: Float32Array) => void): void;
72
111
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
73
112
  * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
74
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
 
@@ -77,14 +80,25 @@ fn hsv(hh: f32) -> vec3<f32> {
77
80
  // instance quaternion). This is exact for any size/aspect/rotation — critical because a bounding SPHERE from the
78
81
  // centre wrongly culls a big flat cube (a ground plane whose centre sits far behind the camera while the ground
79
82
  // underfoot is plainly in view). Mirrors gpuDriven.ts; validated against a CPU box-vs-plane oracle.
83
+ // OCCLUSION CULLING (Hi-Z). After the frustum test, an in-frustum cube is ALSO dropped if it's fully hidden behind
84
+ // nearer geometry. `hiz` is a coarse MAX-depth buffer (each texel = the farthest visible NDC depth over its block)
85
+ // built from the PREVIOUS frame's depth. A cube is occluded iff its nearest corner is behind the farthest occluder
86
+ // across its whole screen footprint. Using last frame's depth + a coarse buffer makes this HOLE-FREE by
87
+ // construction: a newly-disoccluded area has far/cleared depth so nothing there is culled — the worst case is
88
+ // under-culling for one frame, never a false cull. occ.x toggles it (camera pass only; the shadow pass never
89
+ // occludes — an off-screen occluder still casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
80
90
  const CULL_WGSL = /* wgsl */ `
81
- 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> };
82
92
  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
93
+ struct CullCam {
94
+ planes: array<vec4<f32>, 6>, cnt: vec4<f32>, // cnt.x = instance count
95
+ camVP: mat4x4<f32>, occ: vec4<f32>, // occ.x = occlusion on · occ.yz = hiz w,h · occ.w = downscale
96
+ };
84
97
  @group(0) @binding(0) var<storage, read> insts: array<Inst>;
85
98
  @group(0) @binding(1) var<storage, read_write> args: Args;
86
99
  @group(0) @binding(2) var<storage, read_write> visible: array<u32>;
87
100
  @group(0) @binding(3) var<uniform> cc: CullCam;
101
+ @group(0) @binding(4) var hiz: texture_2d<f32>;
88
102
  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
103
  @compute @workgroup_size(${CULL_WG})
90
104
  fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -101,10 +115,65 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
101
115
  let e = abs(dot(pl.xyz, bx)) + abs(dot(pl.xyz, by)) + abs(dot(pl.xyz, bz)); // box half-width along the normal
102
116
  if (dot(pl.xyz, c) + pl.w < -e) { return; }
103
117
  }
118
+ // ── Hi-Z occlusion (camera pass only) ──
119
+ if (cc.occ.x > 0.5) {
120
+ var mn = vec2<f32>(1e9, 1e9);
121
+ var mx = vec2<f32>(-1e9, -1e9);
122
+ var nearZ = 1e9;
123
+ var ok = true;
124
+ for (var ci = 0u; ci < 8u; ci++) {
125
+ 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));
126
+ let corner = c + bx * s.x + by * s.y + bz * s.z;
127
+ let clip = cc.camVP * vec4<f32>(corner, 1.0);
128
+ if (clip.w <= 0.0) { ok = false; break; } // a corner at/behind the eye → don't occlusion-cull (keep)
129
+ let ndc = clip.xyz / clip.w;
130
+ let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5);
131
+ mn = min(mn, uv); mx = max(mx, uv);
132
+ nearZ = min(nearZ, ndc.z);
133
+ }
134
+ if (ok && nearZ >= 0.0) {
135
+ let dim = vec2<f32>(cc.occ.y, cc.occ.z);
136
+ let t0 = vec2<i32>(floor(clamp(mn, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
137
+ let t1 = vec2<i32>(floor(clamp(mx, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
138
+ // only query a bounded footprint; a huge on-screen box just isn't occlusion-tested (kept)
139
+ if (t1.x - t0.x <= 8 && t1.y - t0.y <= 8) {
140
+ var farOccluder = 0.0; // MAX over the footprint of the coarse max-depth
141
+ for (var y = t0.y; y <= t1.y; y++) {
142
+ for (var x = t0.x; x <= t1.x; x++) {
143
+ farOccluder = max(farOccluder, textureLoad(hiz, vec2<i32>(x, y), 0).r);
144
+ }
145
+ }
146
+ if (nearZ > farOccluder) { return; } // behind the farthest occluder everywhere → occluded
147
+ }
148
+ }
149
+ }
104
150
  let slot = atomicAdd(&args.ic, 1u);
105
151
  visible[slot] = i;
106
152
  }
107
153
  `;
154
+ // Hi-Z BUILD: max-reduce the previous frame's depth (a sampleable depth texture) into a coarse R32float buffer —
155
+ // each output texel = the FARTHEST (max) NDC depth over its `down`×`down` source block, so "cube behind this" is a
156
+ // safe occlusion test. depth24plus reads as a depth texture; textureLoad returns the stored [0,1] depth.
157
+ const HIZ_WGSL = /* wgsl */ `
158
+ @group(0) @binding(0) var srcDepth: texture_depth_2d;
159
+ @group(0) @binding(1) var dst: texture_storage_2d<r32float, write>;
160
+ struct P { dim: vec4<u32> }; // dim.xy = dst size, dim.zw = src size
161
+ @group(0) @binding(2) var<uniform> p: P;
162
+ @compute @workgroup_size(8, 8)
163
+ fn build(@builtin(global_invocation_id) gid: vec3<u32>) {
164
+ if (gid.x >= p.dim.x || gid.y >= p.dim.y) { return; }
165
+ let down = 8u;
166
+ var mx = 0.0;
167
+ for (var dy = 0u; dy < down; dy++) {
168
+ for (var dx = 0u; dx < down; dx++) {
169
+ let sx = gid.x * down + dx; let sy = gid.y * down + dy;
170
+ if (sx < p.dim.z && sy < p.dim.w) { mx = max(mx, textureLoad(srcDepth, vec2<i32>(i32(sx), i32(sy)), 0)); }
171
+ }
172
+ }
173
+ textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(mx, 0.0, 0.0, 1.0));
174
+ }
175
+ `;
176
+ const HIZ_DOWN = 8; // source pixels per Hi-Z texel (coarse ⇒ conservative, cheap)
108
177
  const SHADOW_WGSL = COMMON_WGSL + /* wgsl */ `
109
178
  @group(0) @binding(2) var<storage, read> vis: array<u32>;
110
179
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
@@ -115,8 +184,18 @@ const MAIN_WGSL = COMMON_WGSL + /* wgsl */ `
115
184
  @group(0) @binding(2) var shadowMap: texture_depth_2d;
116
185
  @group(0) @binding(3) var shadowSamp: sampler_comparison;
117
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)
118
190
 
119
- 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
+ }
120
199
 
121
200
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VOut {
122
201
  let inst = insts[vis[ii]];
@@ -132,6 +211,7 @@ struct VOut { @builtin(position) clip: vec4<f32>, @location(0) world: vec3<f32>,
132
211
  // by inst.scl so a merged LOD cube (scl>1) still shows its sub-blocks. objN = pre-rotation face normal.
133
212
  o.blk = (CUBE_POS[vi] + vec3(0.5, 0.5, 0.5)) * inst.scl.xyz;
134
213
  o.objN = CUBE_NRM[vi];
214
+ o.tile = inst.aux.x; o.upc = inst.aux.y;
135
215
  return o;
136
216
  }
137
217
 
@@ -166,7 +246,6 @@ fn blockShade(fx: f32, fz: f32) -> f32 {
166
246
  }
167
247
  let N = normalize(i.nrm);
168
248
  let ndl = max(dot(N, -normalize(cam.sun.xyz)), 0.0);
169
- let base = select(mix(vec3(0.34), hsv(i.hue), 0.62), vec3(0.6, 0.62, 0.66), i.hue < 0.0);
170
249
  let sh = shadowFactor(i.world, ndl);
171
250
  // in-block coords = the two OBJECT axes not along the (object-space, axis-aligned) face normal — so the AO
172
251
  // pattern is anchored to the model's own block grid and translates/rotates with the instance.
@@ -175,6 +254,16 @@ fn blockShade(fx: f32, fz: f32) -> f32 {
175
254
  if (an.y >= an.x && an.y >= an.z) { fx = fract(i.blk.x); fz = fract(i.blk.z); }
176
255
  else if (an.x >= an.z) { fx = fract(i.blk.y); fz = fract(i.blk.z); }
177
256
  else { fx = fract(i.blk.x); fz = fract(i.blk.y); }
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
+ }
178
267
  let ao = blockShade(fx, fz);
179
268
  let lit = base * (cam.sun.w + cam.sunCol.xyz * ndl * sh) * ao;
180
269
  return vec4(pow(clamp(lit, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0); // gamma
@@ -211,11 +300,15 @@ export class GpuShadowRenderer {
211
300
  mainBGL;
212
301
  shadowSampler;
213
302
  count = 0;
214
- cam = new Float32Array(40); // 2×mat4 + 2×vec4
303
+ cam = new Float32Array(48); // 2×mat4 + sun + sunCol + tint + atlas
304
+ atlasView;
305
+ atlasSampler;
306
+ tileRectBuf;
307
+ useAtlas = 0;
215
308
  alphaPipe;
216
309
  overlayBuf;
217
310
  overlayBG;
218
- overlayScratch = new Float32Array(OVERLAY_CAP * 12);
311
+ overlayScratch = new Float32Array(OVERLAY_CAP * INST_F);
219
312
  overlayCount = 0;
220
313
  // GPU-driven culling: one compute pipeline; per-frustum (camera + light) indirect args + compacted visible lists.
221
314
  cullPipe;
@@ -232,6 +325,16 @@ export class GpuShadowRenderer {
232
325
  statsBuf; // readback: [camVisible, lightVisible] for the IR/tests
233
326
  /** Last frame's visible counts (main-pass camera cull, shadow-pass light cull) + total — for IR/telemetry. */
234
327
  lastCull = { total: 0, camVisible: 0, lightVisible: 0 };
328
+ // Hi-Z OCCLUSION: a coarse max-depth buffer rebuilt from each frame's depth, sampled by next frame's camera cull.
329
+ hizPipe;
330
+ hizBGL;
331
+ hizTex;
332
+ hizView;
333
+ hizParam;
334
+ hizW = 0;
335
+ hizH = 0;
336
+ hizReady = false; // a Hi-Z from a prior frame exists → safe to occlusion-test (false on frame 1 + resize)
337
+ occView; // the texture bound at cull binding 4 (a 1×1 dummy until occlusion is used)
235
338
  constructor(device, format) {
236
339
  this.device = device;
237
340
  this.camBuf = device.createBuffer({ size: this.cam.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
@@ -256,6 +359,9 @@ export class GpuShadowRenderer {
256
359
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'depth' } },
257
360
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'comparison' } },
258
361
  { binding: 4, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, // camera-visible list
362
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, // block atlas
363
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, // atlas sampler
364
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } }, // per-tile UV rects
259
365
  ] });
260
366
  this.mainPipe = device.createRenderPipeline({
261
367
  layout: device.createPipelineLayout({ bindGroupLayouts: [mainBGL] }),
@@ -293,43 +399,81 @@ export class GpuShadowRenderer {
293
399
  { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // insts
294
400
  { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // args (atomic ic)
295
401
  { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // visible list
296
- { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count
402
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count + camVP + occ
403
+ { binding: 4, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'unfilterable-float' } }, // Hi-Z (dummy until occlusion is used)
297
404
  ] });
298
405
  this.cullPipe = device.createComputePipeline({
299
406
  layout: device.createPipelineLayout({ bindGroupLayouts: [this.cullBGL] }),
300
407
  compute: { module: cullMod, entryPoint: 'cull' },
301
408
  });
409
+ // Hi-Z occlusion build pipeline + a 1×1 dummy Hi-Z (bound when occlusion is off, never sampled)
410
+ const hizMod = device.createShaderModule({ code: HIZ_WGSL });
411
+ this.hizBGL = device.createBindGroupLayout({ entries: [
412
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth' } },
413
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, storageTexture: { access: 'write-only', format: 'r32float' } },
414
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
415
+ ] });
416
+ this.hizPipe = device.createComputePipeline({
417
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.hizBGL] }),
418
+ compute: { module: hizMod, entryPoint: 'build' },
419
+ });
420
+ this.hizParam = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
421
+ const dummy = device.createTexture({ size: [1, 1], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
422
+ this.occView = dummy.createView();
423
+ // atlas dummies (bound until setAtlas): a 1×1 white texture + a 1-entry rect buffer + a plain sampler
424
+ const white = device.createTexture({ size: [1, 1], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
425
+ device.queue.writeTexture({ texture: white }, new Uint8Array([255, 255, 255, 255]), {}, [1, 1]);
426
+ this.atlasView = white.createView();
427
+ this.atlasSampler = device.createSampler({ magFilter: 'nearest', minFilter: 'linear', mipmapFilter: 'linear' });
428
+ this.tileRectBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
302
429
  }
303
430
  // 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.
431
+ // instance capacity, and a uniform holding the 6 planes + instance count + camVP + occlusion params.
305
432
  makeCullBuf() {
306
433
  const d = this.device;
307
434
  const args = d.createBuffer({ size: 4 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
308
435
  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: [
436
+ const cull = d.createBuffer({ size: 6 * 16 + 16 + 64 + 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // planes+cnt+camVP+occ = 192
437
+ return { args, vis, cull };
438
+ }
439
+ cullBG(args, vis, cull) {
440
+ return this.device.createBindGroup({ layout: this.cullBGL, entries: [
311
441
  { binding: 0, resource: { buffer: this.instBuf } }, { binding: 1, resource: { buffer: args } },
312
- { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } }
442
+ { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } },
443
+ { binding: 4, resource: this.occView }
313
444
  ] });
314
- return { args, vis, cull, bg };
315
445
  }
316
446
  makeCullResources() {
317
447
  const cam = this.makeCullBuf();
318
448
  this.camArgs = cam.args;
319
449
  this.camVis = cam.vis;
320
450
  this.camCull = cam.cull;
321
- this.camCullBG = cam.bg;
322
451
  const lit = this.makeCullBuf();
323
452
  this.lightArgs = lit.args;
324
453
  this.lightVis = lit.vis;
325
454
  this.lightCull = lit.cull;
326
- this.lightCullBG = lit.bg;
455
+ this.camCullBG = this.cullBG(this.camArgs, this.camVis, this.camCull);
456
+ this.lightCullBG = this.cullBG(this.lightArgs, this.lightVis, this.lightCull);
327
457
  this.statsBuf = this.device.createBuffer({ size: 4 * 4 * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
328
458
  }
459
+ /** (Re)create the coarse Hi-Z for a `w×h` depth target and rebind the cull groups to it. Idempotent per size. */
460
+ ensureHiZ(w, h) {
461
+ const hw = Math.max(1, Math.ceil(w / HIZ_DOWN)), hh = Math.max(1, Math.ceil(h / HIZ_DOWN));
462
+ if (this.hizTex && this.hizW === hw && this.hizH === hh)
463
+ return;
464
+ this.hizW = hw;
465
+ this.hizH = hh;
466
+ this.hizReady = false; // fresh texture is uninitialized → skip occlusion until it's built once
467
+ this.hizTex = this.device.createTexture({ size: [hw, hh], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
468
+ this.hizView = this.hizTex.createView();
469
+ this.occView = this.hizView; // the cull now samples the real Hi-Z
470
+ this.camCullBG = this.cullBG(this.camArgs, this.camVis, this.camCull);
471
+ this.lightCullBG = this.cullBG(this.lightArgs, this.lightVis, this.lightCull);
472
+ }
329
473
  scratch = new Float32Array(0);
330
474
  fill(scratch, instances, n) {
331
475
  for (let i = 0; i < n; i++) {
332
- const c = instances[i], o = i * 12, r = c.rot;
476
+ const c = instances[i], o = i * INST_F, r = c.rot;
333
477
  scratch[o] = c.x;
334
478
  scratch[o + 1] = c.y;
335
479
  scratch[o + 2] = c.z;
@@ -342,6 +486,10 @@ export class GpuShadowRenderer {
342
486
  scratch[o + 9] = r ? r[1] : 0;
343
487
  scratch[o + 10] = r ? r[2] : 0;
344
488
  scratch[o + 11] = r ? r[3] : 1; // quaternion, default identity
489
+ scratch[o + 12] = c.tile ?? -1;
490
+ scratch[o + 13] = c.up ?? 1;
491
+ scratch[o + 14] = 0;
492
+ scratch[o + 15] = 0; // aux: atlas tile id + up-face code
345
493
  }
346
494
  }
347
495
  makeBindGroups() {
@@ -354,13 +502,37 @@ export class GpuShadowRenderer {
354
502
  { binding: 0, resource: { buffer: this.camBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
355
503
  { binding: 2, resource: this.shadowView }, { binding: 3, resource: this.shadowSampler },
356
504
  { binding: 4, resource: { buffer: this.camVis } },
505
+ { binding: 5, resource: this.atlasView }, { binding: 6, resource: this.atlasSampler }, { binding: 7, resource: { buffer: this.tileRectBuf } },
357
506
  ] });
358
507
  }
508
+ /** Bind a block-texture atlas + its material→tile layout. Each material's top/side/bottom faces sample distinct
509
+ * atlas tiles; a `CubeInstance.tile` of that material id + its `up`-face code selects the right tile in-shader.
510
+ * Call once after the atlas texture is uploaded; instances with `tile < 0` still use the flat hue path. */
511
+ setAtlas(tex, layout) {
512
+ this.atlasView = tex.createView();
513
+ const ids = Object.keys(layout.tiles).map(Number);
514
+ const maxMat = (ids.length ? Math.max(...ids) : 0) + 1;
515
+ const rects = new Float32Array(maxMat * 3 * 4);
516
+ const rectOf = (g) => {
517
+ const cx = g % layout.cols, cy = Math.floor(g / layout.cols);
518
+ return [cx / layout.cols, cy / layout.rows, 1 / layout.cols, 1 / layout.rows];
519
+ };
520
+ for (const [mat, f] of Object.entries(layout.tiles)) {
521
+ const b = Number(mat) * 3 * 4;
522
+ rects.set(rectOf(f.top), b);
523
+ rects.set(rectOf(f.side), b + 4);
524
+ rects.set(rectOf(f.bottom), b + 8);
525
+ }
526
+ this.tileRectBuf = this.device.createBuffer({ size: Math.max(16, rects.byteLength), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
527
+ this.device.queue.writeBuffer(this.tileRectBuf, 0, rects);
528
+ this.useAtlas = 1;
529
+ this.makeBindGroups(); // rebind the main pass with the real atlas + rects
530
+ }
359
531
  /** Static scene: sizes the buffer to exactly these instances. */
360
532
  setScene(instances) {
361
533
  this.count = instances.length;
362
534
  this.cap = this.count;
363
- this.scratch = new Float32Array(this.count * 12);
535
+ this.scratch = new Float32Array(this.count * INST_F);
364
536
  this.fill(this.scratch, instances, this.count);
365
537
  this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
366
538
  this.device.queue.writeBuffer(this.instBuf, 0, this.scratch);
@@ -369,15 +541,15 @@ export class GpuShadowRenderer {
369
541
  /** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
370
542
  setCapacity(cap) {
371
543
  this.cap = cap;
372
- this.scratch = new Float32Array(cap * 12);
544
+ this.scratch = new Float32Array(cap * INST_F);
373
545
  this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
374
546
  this.count = 0;
375
547
  this.makeBindGroups();
376
548
  }
377
549
  updateInstances(instances) {
378
- const n = Math.min(instances.length, this.scratch.length / 12);
550
+ const n = Math.min(instances.length, this.scratch.length / INST_F);
379
551
  this.fill(this.scratch, instances, n);
380
- this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * 12);
552
+ this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * INST_F);
381
553
  this.count = n;
382
554
  }
383
555
  /** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
@@ -385,13 +557,13 @@ export class GpuShadowRenderer {
385
557
  const n = Math.min(instances.length, OVERLAY_CAP);
386
558
  this.fill(this.overlayScratch, instances, n);
387
559
  if (n > 0)
388
- this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * 12);
560
+ this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * INST_F);
389
561
  this.overlayCount = n;
390
562
  }
391
- /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
392
- writeCull(buf, vp) {
563
+ /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
564
+ writeCull(buf, vp, occlude) {
393
565
  const planes = frustumPlanes(vp);
394
- const u = new Float32Array(6 * 4 + 4);
566
+ const u = new Float32Array(48); // planes[24] + cnt[4] + camVP[16] + occ[4]
395
567
  for (let p = 0; p < 6; p++) {
396
568
  u[p * 4] = planes[p][0];
397
569
  u[p * 4 + 1] = planes[p][1];
@@ -399,10 +571,17 @@ export class GpuShadowRenderer {
399
571
  u[p * 4 + 3] = planes[p][3];
400
572
  }
401
573
  u[24] = this.count;
574
+ u.set(vp, 28); // camVP
575
+ u[44] = occlude ? 1 : 0;
576
+ u[45] = this.hizW;
577
+ u[46] = this.hizH;
578
+ u[47] = HIZ_DOWN;
402
579
  this.device.queue.writeBuffer(buf, 0, u);
403
580
  }
404
- /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
405
- frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false) {
581
+ /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
582
+ * its size) to enable Hi-Z occlusion culling on the camera pass — the renderer rebuilds a coarse Hi-Z from that
583
+ * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
584
+ frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false, occlusion, tint = [1, 1, 1, 0], extraShadow) {
406
585
  this.cam.set(camVP, 0);
407
586
  this.cam.set(lightVP, 16);
408
587
  this.cam[32] = sunDir[0];
@@ -413,15 +592,23 @@ export class GpuShadowRenderer {
413
592
  this.cam[37] = sunCol[1];
414
593
  this.cam[38] = sunCol[2];
415
594
  this.cam[39] = SHADOW_SIZE;
595
+ this.cam[40] = tint[0];
596
+ this.cam[41] = tint[1];
597
+ this.cam[42] = tint[2];
598
+ this.cam[43] = tint[3];
599
+ this.cam[44] = this.useAtlas;
416
600
  this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
601
+ if (occlusion)
602
+ this.ensureHiZ(occlusion.w, occlusion.h);
603
+ const occludeNow = !!occlusion && this.hizReady; // only once a Hi-Z from a prior frame exists (never on frame 1/resize)
417
604
  // 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.
605
+ // — main pass vs the camera frustum (+ Hi-Z occlusion), shadow pass vs the light frustum. Each dispatch atomically
606
+ // compacts its survivors into a visible list + counts them in instanceCount, which the matching drawIndirect consumes.
420
607
  const resetArgs = new Uint32Array([36, 0, 0, 0]);
421
608
  this.device.queue.writeBuffer(this.camArgs, 0, resetArgs);
422
609
  this.device.queue.writeBuffer(this.lightArgs, 0, resetArgs);
423
- this.writeCull(this.camCull, camVP);
424
- this.writeCull(this.lightCull, lightVP);
610
+ this.writeCull(this.camCull, camVP, occludeNow);
611
+ this.writeCull(this.lightCull, lightVP, false);
425
612
  if (this.count > 0) {
426
613
  const cp = enc.beginComputePass();
427
614
  cp.setPipeline(this.cullPipe);
@@ -438,8 +625,10 @@ export class GpuShadowRenderer {
438
625
  sp.setPipeline(this.shadowPipe);
439
626
  sp.setBindGroup(0, this.shadowBG);
440
627
  sp.drawIndirect(this.lightArgs, 0);
628
+ if (extraShadow)
629
+ extraShadow(sp, lightVP); // let the app cast extra occluders (mesh props/trees) into the same sun shadow map
441
630
  sp.end();
442
- // Pass 2: camera, shadow-sampled (camera-frustum-culled).
631
+ // Pass 2: camera, shadow-sampled (camera-frustum + Hi-Z-occlusion-culled).
443
632
  const mp = enc.beginRenderPass({
444
633
  colorAttachments: [{ view: colorView, ...(load ? { loadOp: 'load' } : { clearValue: { r: 0.04, g: 0.05, b: 0.07, a: 1 }, loadOp: 'clear' }), storeOp: 'store' }],
445
634
  depthStencilAttachment: { view: sceneDepth, ...(load ? { depthLoadOp: 'load' } : { depthClearValue: 1.0, depthLoadOp: 'clear' }), depthStoreOp: 'store' }
@@ -453,6 +642,19 @@ export class GpuShadowRenderer {
453
642
  mp.draw(36, this.overlayCount);
454
643
  } // translucent overlay last (uncapped, no cull)
455
644
  mp.end();
645
+ // Rebuild the coarse Hi-Z from the depth just written → next frame's cull uses it.
646
+ if (occlusion) {
647
+ this.device.queue.writeBuffer(this.hizParam, 0, new Uint32Array([this.hizW, this.hizH, occlusion.w, occlusion.h]));
648
+ const bg = this.device.createBindGroup({ layout: this.hizBGL, entries: [
649
+ { binding: 0, resource: occlusion.depth }, { binding: 1, resource: this.hizView }, { binding: 2, resource: { buffer: this.hizParam } }
650
+ ] });
651
+ const hp = enc.beginComputePass();
652
+ hp.setPipeline(this.hizPipe);
653
+ hp.setBindGroup(0, bg);
654
+ hp.dispatchWorkgroups(Math.ceil(this.hizW / 8), Math.ceil(this.hizH / 8));
655
+ hp.end();
656
+ this.hizReady = true;
657
+ }
456
658
  this.lastCull.total = this.count;
457
659
  }
458
660
  /** 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.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {