ugly-game 0.7.7 → 0.7.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.
- package/dist/assets/gpuProps.d.ts +33 -0
- package/dist/assets/gpuProps.js +134 -11
- package/dist/gpuShadow.d.ts +16 -2
- package/dist/gpuShadow.js +81 -14
- package/dist/input/browserBackend.js +18 -4
- package/package.json +1 -1
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-world surface + motion parameters.
|
|
3
|
+
*
|
|
4
|
+
* `windAmp` should come from the world's AIR DENSITY: an airless world's flora is perfectly, eerily
|
|
5
|
+
* still, which is both physically correct and an instant read on where you are. `hueShift` and
|
|
6
|
+
* `saturation` carry the star-spectrum grade, so one planet's forest is waxy blue-green and the
|
|
7
|
+
* next is dry and rust-coloured from identical meshes.
|
|
8
|
+
*/
|
|
9
|
+
export interface PropWorld {
|
|
10
|
+
windDir: [number, number];
|
|
11
|
+
/** 0 disables sway entirely (and the vertex path early-outs). */
|
|
12
|
+
windAmp: number;
|
|
13
|
+
/** 0 disables the procedural grain. Larger = finer detail. */
|
|
14
|
+
grainScale: number;
|
|
15
|
+
grainDepth: number;
|
|
16
|
+
roughness: number;
|
|
17
|
+
/** bioluminescence — flat emissive added on dark worlds */
|
|
18
|
+
emissive: number;
|
|
19
|
+
hueShift: number;
|
|
20
|
+
saturation: number;
|
|
21
|
+
valueLift: number;
|
|
22
|
+
}
|
|
1
23
|
export interface PropMesh {
|
|
2
24
|
positions: Float32Array;
|
|
3
25
|
normals: Float32Array;
|
|
@@ -24,6 +46,17 @@ export declare class GpuPropsRenderer {
|
|
|
24
46
|
private shadowBG;
|
|
25
47
|
private lastSV;
|
|
26
48
|
private lastSS;
|
|
49
|
+
/** Per-world look + motion. Defaults are inert: no sway, no grain, no grade. */
|
|
50
|
+
private world;
|
|
51
|
+
private phase;
|
|
52
|
+
/**
|
|
53
|
+
* Set the per-world look and motion. This is the whole point of the procedural surface: the same
|
|
54
|
+
* geometry and the same shader render a different planet's flora by moving a handful of numbers —
|
|
55
|
+
* no per-model textures to author, download, or keep coherent.
|
|
56
|
+
*/
|
|
57
|
+
setWorld(w: Partial<PropWorld>): void;
|
|
58
|
+
/** Advance the wind phase. Pass the frame delta; sway is skipped entirely when windAmp is 0. */
|
|
59
|
+
tick(dt: number): void;
|
|
27
60
|
constructor(device: GPUDevice, format: GPUTextureFormat);
|
|
28
61
|
setModels(meshes: PropMesh[]): void;
|
|
29
62
|
/** Group instances by model and upload per-model transform buffers. */
|
package/dist/assets/gpuProps.js
CHANGED
|
@@ -4,16 +4,37 @@
|
|
|
4
4
|
// terrain. Runs in a load pass after the terrain so props depth-test against the surface.
|
|
5
5
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
6
|
const SHADER = /* wgsl */ `
|
|
7
|
-
|
|
7
|
+
// wind = (dirX, dirZ, amplitude, phase) — sway, amplitude scaled by the world's air density
|
|
8
|
+
// surf = (grainScale, grainDepth, roughness, emissive)
|
|
9
|
+
// grade = (hueShift, saturation, valueLift, unused)
|
|
10
|
+
struct U { mvp: mat4x4<f32>, lightVP: mat4x4<f32>, sun: vec4<f32>, sunCol: vec4<f32>, amb: vec4<f32>, cam: vec4<f32>, fog: vec4<f32>, up: vec4<f32>, wind: vec4<f32>, surf: vec4<f32>, grade: vec4<f32> };
|
|
8
11
|
@group(0) @binding(0) var<uniform> u: U;
|
|
9
12
|
@group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
|
|
10
13
|
@group(1) @binding(0) var shadowMap: texture_depth_2d; // the SAME sun shadow map the terrain cubes sample
|
|
11
14
|
@group(1) @binding(1) var shadowSamp: sampler_comparison;
|
|
12
|
-
struct VO { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec3<f32> };
|
|
15
|
+
struct VO { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec3<f32>, @location(3) l: vec3<f32> };
|
|
16
|
+
// WIND SWAY. Displacement is weighted by height above the instance's own anchor, so a trunk stays
|
|
17
|
+
// planted while the canopy moves, and the phase is offset by world position so a grove never sways
|
|
18
|
+
// in unison. Amplitude comes from the world's air density: an airless world is perfectly, eerily
|
|
19
|
+
// still — physically right, and a strong signal of where you are.
|
|
20
|
+
fn sway(local: vec3<f32>, world: vec3<f32>, up: vec3<f32>, scale: f32) -> vec3<f32> {
|
|
21
|
+
if (u.wind.z <= 0.0001) { return world; }
|
|
22
|
+
let h = max(dot(local, vec3(0.0, 1.0, 0.0)), 0.0); // model +Y is local up (see placeMat)
|
|
23
|
+
let stiff = pow(min(h * 0.22, 1.0), 1.6); // stiff at the base, loose at the tip
|
|
24
|
+
let ph = u.wind.w + dot(world.xz, vec2(0.13, 0.17));
|
|
25
|
+
let a = u.wind.z * stiff * scale;
|
|
26
|
+
let d = a * (sin(ph) + 0.35 * sin(ph * 2.7 + h));
|
|
27
|
+
var wd = vec3(u.wind.x, 0.0, u.wind.y);
|
|
28
|
+
wd = normalize(wd - up * dot(wd, up)); // project into the local tangent plane
|
|
29
|
+
return world + wd * d;
|
|
30
|
+
}
|
|
13
31
|
@vertex fn vs(@location(0) p: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) col: vec3<f32>, @builtin(instance_index) ii: u32) -> VO {
|
|
14
32
|
let m = models[ii];
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
var world = (m * vec4(p, 1.0)).xyz;
|
|
34
|
+
let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
|
|
35
|
+
let sc = length(m[0].xyz);
|
|
36
|
+
world = sway(p, world, upv, sc);
|
|
37
|
+
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; o.l = p; return o;
|
|
17
38
|
}
|
|
18
39
|
// same fixed directional FILL as the terrain cubes (gpuShadow.fixedFill) so plants/rocks read the same 3D shape,
|
|
19
40
|
// day or night, under one shared light. up.w > 0.5 signals a valid local-up was supplied (else fall back to flat).
|
|
@@ -40,22 +61,84 @@ fn shadowFactor(world: vec3<f32>, ndl: f32) -> f32 {
|
|
|
40
61
|
}
|
|
41
62
|
return s / 9.0;
|
|
42
63
|
}
|
|
64
|
+
// ── PROCEDURAL SURFACE ───────────────────────────────────────────────────────
|
|
65
|
+
// Detail is generated in the shader rather than sampled from a texture. Three reasons, all of them
|
|
66
|
+
// load-bearing for a procedurally-populated universe: generated meshes have unreliable UVs (so
|
|
67
|
+
// tri-planar, which needs none); there are no maps to download (the asset budget is already tight);
|
|
68
|
+
// and the SAME code yields a different surface per planet by shifting a handful of parameters, which
|
|
69
|
+
// is exactly what an infinite universe needs. Cohesion survives because every organism resolves
|
|
70
|
+
// through one shared parameter set, not a per-model texture.
|
|
71
|
+
fn hash3(p: vec3<f32>) -> f32 {
|
|
72
|
+
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
|
|
73
|
+
}
|
|
74
|
+
fn vnoise(p: vec3<f32>) -> f32 {
|
|
75
|
+
let i = floor(p); let f = fract(p);
|
|
76
|
+
let w = f * f * (3.0 - 2.0 * f);
|
|
77
|
+
let n000 = hash3(i); let n100 = hash3(i + vec3(1.0, 0.0, 0.0));
|
|
78
|
+
let n010 = hash3(i + vec3(0.0, 1.0, 0.0)); let n110 = hash3(i + vec3(1.0, 1.0, 0.0));
|
|
79
|
+
let n001 = hash3(i + vec3(0.0, 0.0, 1.0)); let n101 = hash3(i + vec3(1.0, 0.0, 1.0));
|
|
80
|
+
let n011 = hash3(i + vec3(0.0, 1.0, 1.0)); let n111 = hash3(i + vec3(1.0, 1.0, 1.0));
|
|
81
|
+
return mix(mix(mix(n000, n100, w.x), mix(n010, n110, w.x), w.y),
|
|
82
|
+
mix(mix(n001, n101, w.x), mix(n011, n111, w.x), w.y), w.z);
|
|
83
|
+
}
|
|
84
|
+
/** Tri-planar fbm — no UVs required, which matters because generated meshes rarely have usable ones. */
|
|
85
|
+
fn triplanar(p: vec3<f32>, n: vec3<f32>, scale: f32) -> f32 {
|
|
86
|
+
let q = p * scale;
|
|
87
|
+
let w = abs(normalize(n));
|
|
88
|
+
let wn = w / max(w.x + w.y + w.z, 0.0001);
|
|
89
|
+
var v = vnoise(vec3(q.y, q.z, 0.0)) * wn.x
|
|
90
|
+
+ vnoise(vec3(q.x, q.z, 1.7)) * wn.y
|
|
91
|
+
+ vnoise(vec3(q.x, q.y, 3.3)) * wn.z;
|
|
92
|
+
v = v * 0.65 + 0.35 * vnoise(q * 2.7 + 11.0); // a second octave for fine break-up
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
fn hsvShift(c: vec3<f32>, hue: f32, sat: f32, val: f32) -> vec3<f32> {
|
|
96
|
+
let k = vec3(0.57735, 0.57735, 0.57735); // rotate about the grey axis
|
|
97
|
+
let cosA = cos(hue); let sinA = sin(hue);
|
|
98
|
+
let rot = c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
|
|
99
|
+
let g = dot(rot, vec3(0.299, 0.587, 0.114));
|
|
100
|
+
return clamp(mix(vec3(g, g, g), rot, sat) * val, vec3(0.0), vec3(4.0));
|
|
101
|
+
}
|
|
43
102
|
@fragment fn fs(i: VO) -> @location(0) vec4<f32> {
|
|
44
103
|
let N = normalize(i.n);
|
|
45
104
|
let ff = select(1.0, 0.5 + 0.85 * fixedFill(N, u.up.xyz), u.up.w > 0.5); // fixed fill (fall back to flat amb if no up)
|
|
46
105
|
let d = clamp(dot(N, -normalize(u.sun.xyz)), 0.0, 1.0);
|
|
47
106
|
let sh = mix(1.0, shadowFactor(i.world, d), u.sun.w); // u.sun.w = shadow-receive enable (0 ⇒ off, byte-identical for demo)
|
|
48
|
-
|
|
107
|
+
// procedural grain modulates the role colour; the per-world grade then re-tints the whole planet
|
|
108
|
+
var base = i.c;
|
|
109
|
+
if (u.surf.x > 0.0) {
|
|
110
|
+
let grain = triplanar(i.l, N, u.surf.x);
|
|
111
|
+
base = base * (1.0 - u.surf.y * 0.5 + u.surf.y * grain);
|
|
112
|
+
}
|
|
113
|
+
base = hsvShift(base, u.grade.x, max(u.grade.y, 0.0), max(u.grade.z, 0.0));
|
|
114
|
+
let spec = u.surf.z;
|
|
115
|
+
let lit = base * (u.amb.xyz * ff + d * u.sunCol.xyz * sh * (1.0 + spec * 0.6))
|
|
116
|
+
+ base * u.surf.w; // emissive — bioluminescence on dark worlds
|
|
49
117
|
let dist = length(i.world - u.cam.xyz);
|
|
50
118
|
let f = clamp(1.0 - exp(-dist * u.fog.w), 0.0, 1.0);
|
|
51
119
|
return vec4(mix(lit, u.fog.xyz, f), 1.0);
|
|
52
120
|
}`;
|
|
53
121
|
// Depth-only occluder pass: instanced prop depth from the sun's ortho view (so trees/rocks cast shadows).
|
|
122
|
+
// Depth-only occluder. It MUST apply the identical sway, or the shadows stay put while the leaves
|
|
123
|
+
// move — a very visible detachment.
|
|
54
124
|
const OCCLUDER = /* wgsl */ `
|
|
55
|
-
|
|
125
|
+
struct OU { lightVP: mat4x4<f32>, wind: vec4<f32> };
|
|
126
|
+
@group(0) @binding(0) var<uniform> o: OU;
|
|
56
127
|
@group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
|
|
57
128
|
@vertex fn vs(@location(0) p: vec3<f32>, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
|
|
58
|
-
|
|
129
|
+
let m = models[ii];
|
|
130
|
+
var world = (m * vec4(p, 1.0)).xyz;
|
|
131
|
+
if (o.wind.z > 0.0001) {
|
|
132
|
+
let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
|
|
133
|
+
let h = max(p.y, 0.0);
|
|
134
|
+
let stiff = pow(min(h * 0.22, 1.0), 1.6);
|
|
135
|
+
let ph = o.wind.w + dot(world.xz, vec2(0.13, 0.17));
|
|
136
|
+
let a = o.wind.z * stiff * length(m[0].xyz);
|
|
137
|
+
var wd = vec3(o.wind.x, 0.0, o.wind.y);
|
|
138
|
+
wd = normalize(wd - upv * dot(wd, upv));
|
|
139
|
+
world = world + wd * (a * (sin(ph) + 0.35 * sin(ph * 2.7 + h)));
|
|
140
|
+
}
|
|
141
|
+
return o.lightVP * vec4(world, 1.0);
|
|
59
142
|
}`;
|
|
60
143
|
export class GpuPropsRenderer {
|
|
61
144
|
device;
|
|
@@ -75,6 +158,32 @@ export class GpuPropsRenderer {
|
|
|
75
158
|
shadowBG = null;
|
|
76
159
|
lastSV = null;
|
|
77
160
|
lastSS = null;
|
|
161
|
+
/** Per-world look + motion. Defaults are inert: no sway, no grain, no grade. */
|
|
162
|
+
world = {
|
|
163
|
+
windDir: [1, 0],
|
|
164
|
+
windAmp: 0,
|
|
165
|
+
grainScale: 0,
|
|
166
|
+
grainDepth: 0,
|
|
167
|
+
roughness: 0,
|
|
168
|
+
emissive: 0,
|
|
169
|
+
hueShift: 0,
|
|
170
|
+
saturation: 1,
|
|
171
|
+
valueLift: 1,
|
|
172
|
+
};
|
|
173
|
+
phase = 0;
|
|
174
|
+
/**
|
|
175
|
+
* Set the per-world look and motion. This is the whole point of the procedural surface: the same
|
|
176
|
+
* geometry and the same shader render a different planet's flora by moving a handful of numbers —
|
|
177
|
+
* no per-model textures to author, download, or keep coherent.
|
|
178
|
+
*/
|
|
179
|
+
setWorld(w) {
|
|
180
|
+
this.world = { ...this.world, ...w };
|
|
181
|
+
}
|
|
182
|
+
/** Advance the wind phase. Pass the frame delta; sway is skipped entirely when windAmp is 0. */
|
|
183
|
+
tick(dt) {
|
|
184
|
+
if (this.world.windAmp > 0)
|
|
185
|
+
this.phase += dt * (0.8 + this.world.windAmp * 2.4);
|
|
186
|
+
}
|
|
78
187
|
constructor(device, format) {
|
|
79
188
|
this.device = device;
|
|
80
189
|
const sm = device.createShaderModule({ code: SHADER });
|
|
@@ -127,11 +236,11 @@ export class GpuPropsRenderer {
|
|
|
127
236
|
},
|
|
128
237
|
});
|
|
129
238
|
this.uBuf = device.createBuffer({
|
|
130
|
-
size: 128 + 16 *
|
|
239
|
+
size: 128 + 16 * 9, // 2× mat4 (mvp + lightVP) + 9× vec4 (…, wind, surf, grade)
|
|
131
240
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
132
241
|
});
|
|
133
242
|
this.lightBuf = device.createBuffer({
|
|
134
|
-
size: 64,
|
|
243
|
+
size: 64 + 16, // lightVP + wind (the occluder sways identically or shadows detach)
|
|
135
244
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
136
245
|
});
|
|
137
246
|
const dummy = device.createTexture({
|
|
@@ -213,7 +322,15 @@ export class GpuPropsRenderer {
|
|
|
213
322
|
}
|
|
214
323
|
/** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
|
|
215
324
|
shadowPass(pass, lightVP) {
|
|
216
|
-
|
|
325
|
+
const ob = new Float32Array(20);
|
|
326
|
+
ob.set(lightVP, 0);
|
|
327
|
+
ob.set([
|
|
328
|
+
this.world.windDir[0],
|
|
329
|
+
this.world.windDir[1],
|
|
330
|
+
this.world.windAmp,
|
|
331
|
+
this.phase,
|
|
332
|
+
], 16);
|
|
333
|
+
this.device.queue.writeBuffer(this.lightBuf, 0, ob);
|
|
217
334
|
pass.setPipeline(this.occPipe);
|
|
218
335
|
for (let m = 0; m < this.models.length; m++) {
|
|
219
336
|
const bg = this.occBGs[m], mdl = this.models[m];
|
|
@@ -230,7 +347,7 @@ export class GpuPropsRenderer {
|
|
|
230
347
|
// in the same shadow. Omit all three (the demo path) → no shadowing, byte-identical to before.
|
|
231
348
|
lightVP, shadowView, shadowSamp) {
|
|
232
349
|
const receive = !!(lightVP && shadowView && shadowSamp);
|
|
233
|
-
const u = new Float32Array(
|
|
350
|
+
const u = new Float32Array(68);
|
|
234
351
|
u.set(camVP, 0);
|
|
235
352
|
u.set(lightVP ?? IDENTITY4, 16); // lightVP (identity when not receiving — result is gated off by sun.w)
|
|
236
353
|
u.set([sunDir[0], sunDir[1], sunDir[2], receive ? 1 : 0], 32); // sun.w = shadow-receive enable
|
|
@@ -240,6 +357,12 @@ export class GpuPropsRenderer {
|
|
|
240
357
|
u.set([fog[0], fog[1], fog[2], fogDensity], 48);
|
|
241
358
|
if (up)
|
|
242
359
|
u.set([up[0], up[1], up[2], 1], 52); // up.w=1 → enable the fixed directional fill (matches the terrain)
|
|
360
|
+
// wind / surface / grade — all default to OFF, so a caller that never calls setWorld() renders
|
|
361
|
+
// byte-identically to before this feature existed.
|
|
362
|
+
const w = this.world;
|
|
363
|
+
u.set([w.windDir[0], w.windDir[1], w.windAmp, this.phase], 56);
|
|
364
|
+
u.set([w.grainScale, w.grainDepth, w.roughness, w.emissive], 60);
|
|
365
|
+
u.set([w.hueShift, w.saturation, w.valueLift, 0], 64);
|
|
243
366
|
this.device.queue.writeBuffer(this.uBuf, 0, u);
|
|
244
367
|
// group 1: the shadow map + comparison sampler (dummy when not receiving). Rebuild only when the view changes.
|
|
245
368
|
const sv = shadowView ?? this.dummyShadowView, ss = shadowSamp ?? this.dummyShadowSamp;
|
package/dist/gpuShadow.d.ts
CHANGED
|
@@ -47,10 +47,13 @@ export declare class GpuShadowRenderer {
|
|
|
47
47
|
private tileRectBuf;
|
|
48
48
|
private useAtlas;
|
|
49
49
|
private alphaPipe;
|
|
50
|
+
private alphaBGL;
|
|
50
51
|
private overlayBuf;
|
|
51
52
|
private overlayBG;
|
|
53
|
+
private overlayCap;
|
|
52
54
|
private overlayScratch;
|
|
53
55
|
private overlayCount;
|
|
56
|
+
private overlayDeferred;
|
|
54
57
|
private cullPipe;
|
|
55
58
|
private cullBGL;
|
|
56
59
|
private cap;
|
|
@@ -96,8 +99,19 @@ export declare class GpuShadowRenderer {
|
|
|
96
99
|
/** Dynamic scene: pre-allocate for `cap` instances; call updateInstances() each frame. */
|
|
97
100
|
setCapacity(cap: number): void;
|
|
98
101
|
updateInstances(instances: CubeInstance[]): void;
|
|
99
|
-
/** Translucent overlay cubes drawn over the opaque scene
|
|
100
|
-
|
|
102
|
+
/** Translucent overlay cubes drawn over the opaque scene; `emissive` carries per-cube alpha (0..1). The buffer
|
|
103
|
+
* GROWS to fit (no hard cap). Pass `eye` to SORT the cubes back-to-front (farthest first) so overlapping
|
|
104
|
+
* translucent cubes blend in the correct order — required for anything denser than the place ghost. By default
|
|
105
|
+
* the cubes draw at the end of frame(); call deferOverlay(true) + overlayPass() to draw them after your own
|
|
106
|
+
* transparent passes (e.g. water) so those don't paint over them. */
|
|
107
|
+
updateOverlay(instances: CubeInstance[], eye?: [number, number, number]): void;
|
|
108
|
+
/** Defer the translucent overlay out of frame() so the caller draws it via overlayPass() AFTER its own
|
|
109
|
+
* transparent geometry (e.g. water). Otherwise those later passes paint over the overlay. */
|
|
110
|
+
deferOverlay(on: boolean): void;
|
|
111
|
+
/** Draw the translucent overlay in its own pass over `colorView`, depth-tested (read-only) against `depthView`
|
|
112
|
+
* — same pipeline as the in-frame draw, just callable after other transparent passes. Pairs with
|
|
113
|
+
* deferOverlay(true). No-op if the overlay is empty. */
|
|
114
|
+
overlayPass(enc: GPUCommandEncoder, colorView: GPUTextureView, depthView: GPUTextureView): void;
|
|
101
115
|
/** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
|
|
102
116
|
private writeCull;
|
|
103
117
|
/** The sun shadow-map depth view + its comparison sampler + texel size. Feed these into GpuPropsRenderer/
|
package/dist/gpuShadow.js
CHANGED
|
@@ -91,10 +91,12 @@ fn hsv(hh: f32) -> vec3<f32> {
|
|
|
91
91
|
// OCCLUSION CULLING (Hi-Z). After the frustum test, an in-frustum cube is ALSO dropped if it's fully hidden behind
|
|
92
92
|
// nearer geometry. `hiz` is a coarse MAX-depth buffer (each texel = the farthest visible NDC depth over its block)
|
|
93
93
|
// built from the PREVIOUS frame's depth. A cube is occluded iff its nearest corner is behind the farthest occluder
|
|
94
|
-
// across its whole screen footprint.
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
94
|
+
// across its whole screen footprint. Because the depth is one frame STALE, a cube reprojects a few pixels under
|
|
95
|
+
// camera motion and can be tested against near depth from where an occluder used to be — a false cull that blinks
|
|
96
|
+
// the cube out for a frame. To absorb that, the footprint query is DILATED by 2 Hi-Z texels (see below); MAX over
|
|
97
|
+
// the larger region only ever raises the occluder distance, so it strictly keeps more cubes and never hides real
|
|
98
|
+
// geometry. occ.x toggles it (camera pass only; the shadow pass never occludes — an off-screen occluder still
|
|
99
|
+
// casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
|
|
98
100
|
const CULL_WGSL = /* wgsl */ `
|
|
99
101
|
struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32>, aux: vec4<f32> };
|
|
100
102
|
struct Args { vc: u32, ic: atomic<u32>, fv: u32, fi: u32 };
|
|
@@ -145,9 +147,18 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
|
145
147
|
let t1 = vec2<i32>(floor(clamp(mx, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
|
|
146
148
|
// only query a bounded footprint; a huge on-screen box just isn't occlusion-tested (kept)
|
|
147
149
|
if (t1.x - t0.x <= 8 && t1.y - t0.y <= 8) {
|
|
150
|
+
// DILATE the query by 2 Hi-Z texels. hiz is LAST frame's depth; under camera motion a cube reprojects by
|
|
151
|
+
// a few source pixels between frames, so a cube sitting at an occluder's silhouette gets tested against
|
|
152
|
+
// stale NEAR depth from where the occluder used to be and is wrongly culled — it blinks out for a frame
|
|
153
|
+
// (worst on thin/sparse geometry, but a one-frame hole in dense terrain too). Taking MAX over a dilated
|
|
154
|
+
// footprint is strictly MORE conservative (it can only raise farOccluder ⇒ only ever KEEP more cubes), so it
|
|
155
|
+
// never hides real geometry; it just leaves a couple of deeply-hidden cubes unculled. 2×8px of slack.
|
|
156
|
+
let lim = vec2<i32>(i32(cc.occ.y) - 1, i32(cc.occ.z) - 1);
|
|
157
|
+
let q0 = max(t0 - vec2<i32>(2, 2), vec2<i32>(0, 0));
|
|
158
|
+
let q1 = min(t1 + vec2<i32>(2, 2), lim);
|
|
148
159
|
var farOccluder = 0.0; // MAX over the footprint of the coarse max-depth
|
|
149
|
-
for (var y =
|
|
150
|
-
for (var x =
|
|
160
|
+
for (var y = q0.y; y <= q1.y; y++) {
|
|
161
|
+
for (var x = q0.x; x <= q1.x; x++) {
|
|
151
162
|
farOccluder = max(farOccluder, textureLoad(hiz, vec2<i32>(x, y), 0).r);
|
|
152
163
|
}
|
|
153
164
|
}
|
|
@@ -341,10 +352,13 @@ export class GpuShadowRenderer {
|
|
|
341
352
|
tileRectBuf;
|
|
342
353
|
useAtlas = 0;
|
|
343
354
|
alphaPipe;
|
|
355
|
+
alphaBGL; // kept so the overlay buffer can grow and rebind on demand
|
|
344
356
|
overlayBuf;
|
|
345
357
|
overlayBG;
|
|
358
|
+
overlayCap = OVERLAY_CAP; // grows to fit — the overlay is no longer hard-capped
|
|
346
359
|
overlayScratch = new Float32Array(OVERLAY_CAP * INST_F);
|
|
347
360
|
overlayCount = 0;
|
|
361
|
+
overlayDeferred = false; // when true, frame() does NOT draw the overlay — the caller draws it via overlayPass()
|
|
348
362
|
// GPU-driven culling: one compute pipeline; per-frustum (camera + light) indirect args + compacted visible lists.
|
|
349
363
|
cullPipe;
|
|
350
364
|
cullBGL;
|
|
@@ -473,7 +487,7 @@ export class GpuShadowRenderer {
|
|
|
473
487
|
});
|
|
474
488
|
// translucent overlay: alpha-blended, depth-tested but not depth-writing (drawn after opaque)
|
|
475
489
|
const alphaMod = device.createShaderModule({ code: ALPHA_WGSL });
|
|
476
|
-
const alphaBGL = device.createBindGroupLayout({
|
|
490
|
+
const alphaBGL = (this.alphaBGL = device.createBindGroupLayout({
|
|
477
491
|
entries: [
|
|
478
492
|
{
|
|
479
493
|
binding: 0,
|
|
@@ -486,7 +500,7 @@ export class GpuShadowRenderer {
|
|
|
486
500
|
buffer: { type: 'read-only-storage' },
|
|
487
501
|
},
|
|
488
502
|
],
|
|
489
|
-
});
|
|
503
|
+
}));
|
|
490
504
|
this.alphaPipe = device.createRenderPipeline({
|
|
491
505
|
layout: device.createPipelineLayout({ bindGroupLayouts: [alphaBGL] }),
|
|
492
506
|
vertex: { module: alphaMod, entryPoint: 'vs' },
|
|
@@ -791,14 +805,67 @@ export class GpuShadowRenderer {
|
|
|
791
805
|
this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * INST_F);
|
|
792
806
|
this.count = n;
|
|
793
807
|
}
|
|
794
|
-
/** Translucent overlay cubes drawn over the opaque scene
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
808
|
+
/** Translucent overlay cubes drawn over the opaque scene; `emissive` carries per-cube alpha (0..1). The buffer
|
|
809
|
+
* GROWS to fit (no hard cap). Pass `eye` to SORT the cubes back-to-front (farthest first) so overlapping
|
|
810
|
+
* translucent cubes blend in the correct order — required for anything denser than the place ghost. By default
|
|
811
|
+
* the cubes draw at the end of frame(); call deferOverlay(true) + overlayPass() to draw them after your own
|
|
812
|
+
* transparent passes (e.g. water) so those don't paint over them. */
|
|
813
|
+
updateOverlay(instances, eye) {
|
|
814
|
+
let list = instances;
|
|
815
|
+
if (eye && instances.length > 1) {
|
|
816
|
+
// back-to-front: a nearer translucent cube must blend OVER a farther one, so draw the farthest first
|
|
817
|
+
list = instances
|
|
818
|
+
.map((c) => ({
|
|
819
|
+
c,
|
|
820
|
+
d: (c.x - eye[0]) * (c.x - eye[0]) +
|
|
821
|
+
(c.y - eye[1]) * (c.y - eye[1]) +
|
|
822
|
+
(c.z - eye[2]) * (c.z - eye[2]),
|
|
823
|
+
}))
|
|
824
|
+
.sort((a, b) => b.d - a.d)
|
|
825
|
+
.map((o) => o.c);
|
|
826
|
+
}
|
|
827
|
+
const n = list.length;
|
|
828
|
+
if (n > this.overlayCap) {
|
|
829
|
+
this.overlayCap = Math.ceil(n * 1.5);
|
|
830
|
+
this.overlayScratch = new Float32Array(this.overlayCap * INST_F);
|
|
831
|
+
this.overlayBuf.destroy();
|
|
832
|
+
this.overlayBuf = this.device.createBuffer({
|
|
833
|
+
size: this.overlayScratch.byteLength,
|
|
834
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
835
|
+
});
|
|
836
|
+
this.overlayBG = this.device.createBindGroup({
|
|
837
|
+
layout: this.alphaBGL,
|
|
838
|
+
entries: [
|
|
839
|
+
{ binding: 0, resource: { buffer: this.camBuf } },
|
|
840
|
+
{ binding: 1, resource: { buffer: this.overlayBuf } },
|
|
841
|
+
],
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
this.fill(this.overlayScratch, list, n);
|
|
798
845
|
if (n > 0)
|
|
799
846
|
this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * INST_F);
|
|
800
847
|
this.overlayCount = n;
|
|
801
848
|
}
|
|
849
|
+
/** Defer the translucent overlay out of frame() so the caller draws it via overlayPass() AFTER its own
|
|
850
|
+
* transparent geometry (e.g. water). Otherwise those later passes paint over the overlay. */
|
|
851
|
+
deferOverlay(on) {
|
|
852
|
+
this.overlayDeferred = on;
|
|
853
|
+
}
|
|
854
|
+
/** Draw the translucent overlay in its own pass over `colorView`, depth-tested (read-only) against `depthView`
|
|
855
|
+
* — same pipeline as the in-frame draw, just callable after other transparent passes. Pairs with
|
|
856
|
+
* deferOverlay(true). No-op if the overlay is empty. */
|
|
857
|
+
overlayPass(enc, colorView, depthView) {
|
|
858
|
+
if (this.overlayCount === 0)
|
|
859
|
+
return;
|
|
860
|
+
const mp = enc.beginRenderPass({
|
|
861
|
+
colorAttachments: [{ view: colorView, loadOp: 'load', storeOp: 'store' }],
|
|
862
|
+
depthStencilAttachment: { view: depthView, depthReadOnly: true },
|
|
863
|
+
});
|
|
864
|
+
mp.setPipeline(this.alphaPipe);
|
|
865
|
+
mp.setBindGroup(0, this.overlayBG);
|
|
866
|
+
mp.draw(36, this.overlayCount);
|
|
867
|
+
mp.end();
|
|
868
|
+
}
|
|
802
869
|
/** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
|
|
803
870
|
writeCull(buf, vp, occlude) {
|
|
804
871
|
const planes = frustumPlanes(vp);
|
|
@@ -914,11 +981,11 @@ export class GpuShadowRenderer {
|
|
|
914
981
|
mp.setPipeline(this.mainPipe);
|
|
915
982
|
mp.setBindGroup(0, this.mainBG);
|
|
916
983
|
mp.drawIndirect(this.camArgs, 0);
|
|
917
|
-
if (this.overlayCount > 0) {
|
|
984
|
+
if (!this.overlayDeferred && this.overlayCount > 0) {
|
|
918
985
|
mp.setPipeline(this.alphaPipe);
|
|
919
986
|
mp.setBindGroup(0, this.overlayBG);
|
|
920
987
|
mp.draw(36, this.overlayCount);
|
|
921
|
-
} // translucent overlay last (
|
|
988
|
+
} // translucent overlay last (no cull) — unless deferred to overlayPass() (drawn after the caller's water etc.)
|
|
922
989
|
mp.end();
|
|
923
990
|
// Rebuild the coarse Hi-Z from the depth just written → next frame's cull uses it.
|
|
924
991
|
if (occlusion) {
|
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
// • touch: one finger = cursor (tap = place edge); two fingers = pan + pinch-zoom + twist.
|
|
13
13
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
14
|
import { emptyFrame } from './resolve';
|
|
15
|
+
// setPointerCapture throws InvalidStateError when a pointer-lock request is still in-flight or the pointerId is no
|
|
16
|
+
// longer active. A failed capture only means an off-canvas drag won't keep tracking — never fatal — so swallow it
|
|
17
|
+
// rather than let it bubble as an "Uncaught InvalidStateError".
|
|
18
|
+
function safeCapture(el, pointerId) {
|
|
19
|
+
try {
|
|
20
|
+
el.setPointerCapture(pointerId);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* pointer inactive or lock pending — capture simply doesn't apply this gesture */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
export class BrowserRawBackend {
|
|
16
27
|
keysDown = new Set();
|
|
17
28
|
edges = new Set();
|
|
@@ -121,7 +132,7 @@ export class BrowserRawBackend {
|
|
|
121
132
|
this.touches.set(e.pointerId, { x: e.clientX, y: e.clientY, moved: 0 });
|
|
122
133
|
this.gPrev = this.touches.size === 2 ? this.gesture() : null;
|
|
123
134
|
if (!document.pointerLockElement)
|
|
124
|
-
el
|
|
135
|
+
safeCapture(el, e.pointerId); // track gestures that leave the canvas
|
|
125
136
|
}
|
|
126
137
|
else {
|
|
127
138
|
if (!this.pointerButtons.has(e.button))
|
|
@@ -130,8 +141,11 @@ export class BrowserRawBackend {
|
|
|
130
141
|
// Pointer LOCK and pointer CAPTURE are mutually-exclusive ways to route pointer input. Calling capture
|
|
131
142
|
// while lock is engaged/pending throws InvalidStateError (Firefox then never locks → no mouselook;
|
|
132
143
|
// Chrome logs it on every click while locked). So when the game wants mouselook use ONLY pointer lock;
|
|
133
|
-
// otherwise capture so a drag keeps tracking off-canvas. `document.pointerLockElement`
|
|
134
|
-
//
|
|
144
|
+
// otherwise capture so a drag keeps tracking off-canvas. `document.pointerLockElement` narrows the window,
|
|
145
|
+
// but it's NOT sufficient: a click landing while a requestPointerLock() is still PENDING (lockElement is
|
|
146
|
+
// null but the browser has a lock in-flight), or on a pointerId the UA no longer considers active, still
|
|
147
|
+
// throws InvalidStateError — which surfaced as an "Uncaught InvalidStateError" in prod. `safeCapture`
|
|
148
|
+
// swallows it (a failed capture just means a drag won't track past the canvas edge — harmless).
|
|
135
149
|
if (this.pointerLock) {
|
|
136
150
|
if (!document.pointerLockElement)
|
|
137
151
|
void Promise.resolve(el.requestPointerLock()).catch(() => {
|
|
@@ -139,7 +153,7 @@ export class BrowserRawBackend {
|
|
|
139
153
|
});
|
|
140
154
|
}
|
|
141
155
|
else if (!document.pointerLockElement) {
|
|
142
|
-
el
|
|
156
|
+
safeCapture(el, e.pointerId);
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
});
|