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.
- package/dist/assets/gpuProps.d.ts +29 -0
- package/dist/assets/gpuProps.js +147 -0
- package/dist/gpuShadow.d.ts +42 -3
- package/dist/gpuShadow.js +233 -31
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/gpuShadow.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 *
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 *
|
|
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 *
|
|
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 *
|
|
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 /
|
|
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 *
|
|
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 *
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
419
|
-
// survivors into a visible list + counts them in instanceCount, which the matching drawIndirect
|
|
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
|