ugly-game 0.7.8 → 0.7.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 +71 -1
- package/dist/assets/gpuProps.js +321 -49
- package/dist/gpuShadow.js +6 -4
- package/dist/input/browserBackend.js +18 -4
- package/package.json +1 -1
|
@@ -1,12 +1,62 @@
|
|
|
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;
|
|
4
26
|
colors: Float32Array;
|
|
5
27
|
indices: Uint32Array;
|
|
6
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Per-instance deformation. Every field is neutral at its default, so an instance that supplies none
|
|
31
|
+
* renders byte-identically to one drawn before this existed.
|
|
32
|
+
*
|
|
33
|
+
* This is the cheap half of "variety": one generated mesh becomes an unlimited stand of visibly
|
|
34
|
+
* different plants without a second asset, a second draw call, or a provider call. The expensive
|
|
35
|
+
* half — new silhouettes — still needs a new mesh.
|
|
36
|
+
*/
|
|
37
|
+
export interface PropDeform {
|
|
38
|
+
/** Vertical scale. 0.6 squat, 1.6 lanky. */
|
|
39
|
+
height?: number;
|
|
40
|
+
/** Horizontal scale, applied before taper. */
|
|
41
|
+
girth?: number;
|
|
42
|
+
/** >0 narrows toward the top, <0 flares. */
|
|
43
|
+
taper?: number;
|
|
44
|
+
/** Lateral tip displacement in units of the plant's own height; grows as u². */
|
|
45
|
+
bend?: number;
|
|
46
|
+
bendDir?: number;
|
|
47
|
+
/** Radians of twist from base to tip. */
|
|
48
|
+
twist?: number;
|
|
49
|
+
/** Downward pull on geometry that reaches out — a trunk is unaffected, a frond sags. */
|
|
50
|
+
droop?: number;
|
|
51
|
+
yaw?: number;
|
|
52
|
+
/** Multiplied into vertex colour; gives a within-species value spread. */
|
|
53
|
+
valueJitter?: number;
|
|
54
|
+
}
|
|
7
55
|
export interface PropInstance {
|
|
8
56
|
model: number;
|
|
9
57
|
mat: Float32Array;
|
|
58
|
+
/** Optional per-instance deform. Omitted ⇒ the mesh is drawn exactly as authored. */
|
|
59
|
+
deform?: PropDeform;
|
|
10
60
|
}
|
|
11
61
|
export declare class GpuPropsRenderer {
|
|
12
62
|
private device;
|
|
@@ -16,6 +66,8 @@ export declare class GpuPropsRenderer {
|
|
|
16
66
|
private lightBuf;
|
|
17
67
|
private models;
|
|
18
68
|
private instBufs;
|
|
69
|
+
/** Allocated capacity in instances, so a per-frame LOD re-bucket reuses the buffer. */
|
|
70
|
+
private caps;
|
|
19
71
|
private bgs;
|
|
20
72
|
private occBGs;
|
|
21
73
|
private counts;
|
|
@@ -24,9 +76,27 @@ export declare class GpuPropsRenderer {
|
|
|
24
76
|
private shadowBG;
|
|
25
77
|
private lastSV;
|
|
26
78
|
private lastSS;
|
|
79
|
+
/** Per-world look + motion. Defaults are inert: no sway, no grain, no grade. */
|
|
80
|
+
private world;
|
|
81
|
+
private phase;
|
|
82
|
+
/**
|
|
83
|
+
* Set the per-world look and motion. This is the whole point of the procedural surface: the same
|
|
84
|
+
* geometry and the same shader render a different planet's flora by moving a handful of numbers —
|
|
85
|
+
* no per-model textures to author, download, or keep coherent.
|
|
86
|
+
*/
|
|
87
|
+
setWorld(w: Partial<PropWorld>): void;
|
|
88
|
+
/** Advance the wind phase. Pass the frame delta; sway is skipped entirely when windAmp is 0. */
|
|
89
|
+
tick(dt: number): void;
|
|
27
90
|
constructor(device: GPUDevice, format: GPUTextureFormat);
|
|
28
91
|
setModels(meshes: PropMesh[]): void;
|
|
29
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Group instances by model and upload the per-model instance buffers.
|
|
94
|
+
*
|
|
95
|
+
* Safe to call every frame: buffers are REUSED whenever the new count fits the allocated capacity,
|
|
96
|
+
* and capacity grows with headroom. That matters because distance-bucketed LOD means an instance
|
|
97
|
+
* changes which model it belongs to as the camera moves, so the grouping is not a one-time setup —
|
|
98
|
+
* re-allocating a megabyte of storage buffers per frame would cost more than the LOD saves.
|
|
99
|
+
*/
|
|
30
100
|
setInstances(instances: PropInstance[]): void;
|
|
31
101
|
/** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
|
|
32
102
|
shadowPass(pass: GPURenderPassEncoder, lightVP: Float32Array): void;
|
package/dist/assets/gpuProps.js
CHANGED
|
@@ -4,16 +4,95 @@
|
|
|
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
|
+
// One instance. 'm' is the placement; d0/d1/d2 are the per-instance DEFORM, which is what lets
|
|
13
|
+
// thousands of visibly different plants share a single mesh and a single draw call. Baking each
|
|
14
|
+
// variant on the CPU instead costs one model, one vertex buffer and one draw call per plant —
|
|
15
|
+
// measured, that is the difference between a forest and a slideshow.
|
|
16
|
+
// d0 = (heightScale, girth, taper, 1/sourceHeight)
|
|
17
|
+
// d1 = (bend, bendDir, twist, droop)
|
|
18
|
+
// d2 = (yaw, valueJitter, 1/halfWidth, unused)
|
|
19
|
+
struct Inst { m: mat4x4<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32> };
|
|
20
|
+
@group(0) @binding(1) var<storage,read> models: array<Inst>;
|
|
10
21
|
@group(1) @binding(0) var shadowMap: texture_depth_2d; // the SAME sun shadow map the terrain cubes sample
|
|
11
22
|
@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> };
|
|
23
|
+
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> };
|
|
24
|
+
// WIND SWAY. Displacement is weighted by height above the instance's own anchor, so a trunk stays
|
|
25
|
+
// planted while the canopy moves, and the phase is offset by world position so a grove never sways
|
|
26
|
+
// in unison. Amplitude comes from the world's air density: an airless world is perfectly, eerily
|
|
27
|
+
// still — physically right, and a strong signal of where you are.
|
|
28
|
+
fn sway(local: vec3<f32>, world: vec3<f32>, up: vec3<f32>, scale: f32) -> vec3<f32> {
|
|
29
|
+
if (u.wind.z <= 0.0001) { return world; }
|
|
30
|
+
let h = max(dot(local, vec3(0.0, 1.0, 0.0)), 0.0); // model +Y is local up (see placeMat)
|
|
31
|
+
let stiff = pow(min(h * 0.22, 1.0), 1.6); // stiff at the base, loose at the tip
|
|
32
|
+
let ph = u.wind.w + dot(world.xz, vec2(0.13, 0.17));
|
|
33
|
+
let a = u.wind.z * stiff * scale;
|
|
34
|
+
let d = a * (sin(ph) + 0.35 * sin(ph * 2.7 + h));
|
|
35
|
+
var wd = vec3(u.wind.x, 0.0, u.wind.y);
|
|
36
|
+
wd = normalize(wd - up * dot(wd, up)); // project into the local tangent plane
|
|
37
|
+
return world + wd * d;
|
|
38
|
+
}
|
|
39
|
+
// PER-INSTANCE DEFORM, in local space, before placement. Mirrors shared/lifeVariation.ts deform()
|
|
40
|
+
// exactly: the CPU version is the reference and the two must agree, or the same seed gives a
|
|
41
|
+
// different plant depending on which path drew it.
|
|
42
|
+
//
|
|
43
|
+
// Assumes the model's base sits at y = 0, which the cook guarantees (it normalises every plant to
|
|
44
|
+
// base-at-origin). u then runs 0 at the base to 1 at the tip, and everything is anchored: a deformed
|
|
45
|
+
// plant still meets the ground exactly where the original did, so no instance needs its contact
|
|
46
|
+
// re-solved.
|
|
47
|
+
fn deformLocal(p: vec3<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32>) -> vec3<f32> {
|
|
48
|
+
let H = 1.0 / max(d0.w, 1e-6);
|
|
49
|
+
let u = clamp(p.y * d0.w, 0.0, 1.0);
|
|
50
|
+
var x = p.x; var y = p.y; var z = p.z;
|
|
51
|
+
let rs = d0.y * (1.0 + d0.z * (1.0 - 2.0 * u)); // taper about the vertical axis
|
|
52
|
+
x = x * rs; z = z * rs;
|
|
53
|
+
let a = d1.z * u; // twist accumulates with height
|
|
54
|
+
let ca = cos(a); let sa = sin(a);
|
|
55
|
+
let tx = x * ca - z * sa; let tz = x * sa + z * ca;
|
|
56
|
+
x = tx; z = tz;
|
|
57
|
+
y = y * d0.x;
|
|
58
|
+
if (d1.w != 0.0) { // droop: weighted by radial reach
|
|
59
|
+
let r = sqrt(x * x + z * z) * d2.z;
|
|
60
|
+
y = y - d1.w * H * d0.x * r * r * u;
|
|
61
|
+
}
|
|
62
|
+
let dd = d1.x * H * d0.x * u * u; // bend as u²: leaves the ground vertical
|
|
63
|
+
x = x + cos(d1.y) * dd;
|
|
64
|
+
z = z + sin(d1.y) * dd;
|
|
65
|
+
let cy = cos(d2.x); let sy = sin(d2.x);
|
|
66
|
+
return vec3(x * cy - z * sy, y, x * sy + z * cy);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Normal under the same deform, approximated.
|
|
70
|
+
*
|
|
71
|
+
* The exact answer is the inverse-transpose of the deform's Jacobian, which for a bend and a droop
|
|
72
|
+
* is not cheap. Taper and non-uniform height are the terms that actually matter to shading, so the
|
|
73
|
+
* normal gets the inverse non-uniform scale plus the rigid rotations (twist + yaw) and nothing else.
|
|
74
|
+
* The CPU path recomputes normals exactly; the perf lab renders both so the difference can be looked
|
|
75
|
+
* at rather than assumed.
|
|
76
|
+
*/
|
|
77
|
+
fn deformNormal(n: vec3<f32>, py: f32, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32>) -> vec3<f32> {
|
|
78
|
+
let u = clamp(py * d0.w, 0.0, 1.0);
|
|
79
|
+
let rs = max(d0.y * (1.0 + d0.z * (1.0 - 2.0 * u)), 1e-4);
|
|
80
|
+
var v = vec3(n.x / rs, n.y / max(d0.x, 1e-4), n.z / rs);
|
|
81
|
+
let a = d1.z * u + d2.x;
|
|
82
|
+
let ca = cos(a); let sa = sin(a);
|
|
83
|
+
return normalize(vec3(v.x * ca - v.z * sa, v.y, v.x * sa + v.z * ca));
|
|
84
|
+
}
|
|
13
85
|
@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
|
-
let
|
|
15
|
-
let
|
|
16
|
-
|
|
86
|
+
let inst = models[ii];
|
|
87
|
+
let m = inst.m;
|
|
88
|
+
let lp = deformLocal(p, inst.d0, inst.d1, inst.d2);
|
|
89
|
+
let ln = deformNormal(nrm, p.y, inst.d0, inst.d1, inst.d2);
|
|
90
|
+
var world = (m * vec4(lp, 1.0)).xyz;
|
|
91
|
+
let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
|
|
92
|
+
let sc = length(m[0].xyz);
|
|
93
|
+
world = sway(lp, world, upv, sc);
|
|
94
|
+
var o: VO; o.pos = u.mvp * vec4(world, 1.0); o.world = world; o.n = (m * vec4(ln, 0.0)).xyz;
|
|
95
|
+
o.c = col * inst.d2.y; o.l = lp; return o;
|
|
17
96
|
}
|
|
18
97
|
// same fixed directional FILL as the terrain cubes (gpuShadow.fixedFill) so plants/rocks read the same 3D shape,
|
|
19
98
|
// day or night, under one shared light. up.w > 0.5 signals a valid local-up was supplied (else fall back to flat).
|
|
@@ -40,23 +119,114 @@ fn shadowFactor(world: vec3<f32>, ndl: f32) -> f32 {
|
|
|
40
119
|
}
|
|
41
120
|
return s / 9.0;
|
|
42
121
|
}
|
|
122
|
+
// ── PROCEDURAL SURFACE ───────────────────────────────────────────────────────
|
|
123
|
+
// Detail is generated in the shader rather than sampled from a texture. Three reasons, all of them
|
|
124
|
+
// load-bearing for a procedurally-populated universe: generated meshes have unreliable UVs (so
|
|
125
|
+
// tri-planar, which needs none); there are no maps to download (the asset budget is already tight);
|
|
126
|
+
// and the SAME code yields a different surface per planet by shifting a handful of parameters, which
|
|
127
|
+
// is exactly what an infinite universe needs. Cohesion survives because every organism resolves
|
|
128
|
+
// through one shared parameter set, not a per-model texture.
|
|
129
|
+
fn hash3(p: vec3<f32>) -> f32 {
|
|
130
|
+
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
|
|
131
|
+
}
|
|
132
|
+
fn vnoise(p: vec3<f32>) -> f32 {
|
|
133
|
+
let i = floor(p); let f = fract(p);
|
|
134
|
+
let w = f * f * (3.0 - 2.0 * f);
|
|
135
|
+
let n000 = hash3(i); let n100 = hash3(i + vec3(1.0, 0.0, 0.0));
|
|
136
|
+
let n010 = hash3(i + vec3(0.0, 1.0, 0.0)); let n110 = hash3(i + vec3(1.0, 1.0, 0.0));
|
|
137
|
+
let n001 = hash3(i + vec3(0.0, 0.0, 1.0)); let n101 = hash3(i + vec3(1.0, 0.0, 1.0));
|
|
138
|
+
let n011 = hash3(i + vec3(0.0, 1.0, 1.0)); let n111 = hash3(i + vec3(1.0, 1.0, 1.0));
|
|
139
|
+
return mix(mix(mix(n000, n100, w.x), mix(n010, n110, w.x), w.y),
|
|
140
|
+
mix(mix(n001, n101, w.x), mix(n011, n111, w.x), w.y), w.z);
|
|
141
|
+
}
|
|
142
|
+
/** Tri-planar fbm — no UVs required, which matters because generated meshes rarely have usable ones. */
|
|
143
|
+
fn triplanar(p: vec3<f32>, n: vec3<f32>, scale: f32) -> f32 {
|
|
144
|
+
let q = p * scale;
|
|
145
|
+
let w = abs(normalize(n));
|
|
146
|
+
let wn = w / max(w.x + w.y + w.z, 0.0001);
|
|
147
|
+
var v = vnoise(vec3(q.y, q.z, 0.0)) * wn.x
|
|
148
|
+
+ vnoise(vec3(q.x, q.z, 1.7)) * wn.y
|
|
149
|
+
+ vnoise(vec3(q.x, q.y, 3.3)) * wn.z;
|
|
150
|
+
v = v * 0.65 + 0.35 * vnoise(q * 2.7 + 11.0); // a second octave for fine break-up
|
|
151
|
+
return v;
|
|
152
|
+
}
|
|
153
|
+
fn hsvShift(c: vec3<f32>, hue: f32, sat: f32, val: f32) -> vec3<f32> {
|
|
154
|
+
let k = vec3(0.57735, 0.57735, 0.57735); // rotate about the grey axis
|
|
155
|
+
let cosA = cos(hue); let sinA = sin(hue);
|
|
156
|
+
let rot = c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
|
|
157
|
+
let g = dot(rot, vec3(0.299, 0.587, 0.114));
|
|
158
|
+
return clamp(mix(vec3(g, g, g), rot, sat) * val, vec3(0.0), vec3(4.0));
|
|
159
|
+
}
|
|
43
160
|
@fragment fn fs(i: VO) -> @location(0) vec4<f32> {
|
|
44
161
|
let N = normalize(i.n);
|
|
45
162
|
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
163
|
let d = clamp(dot(N, -normalize(u.sun.xyz)), 0.0, 1.0);
|
|
47
164
|
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
|
-
|
|
165
|
+
// procedural grain modulates the role colour; the per-world grade then re-tints the whole planet
|
|
166
|
+
var base = i.c;
|
|
167
|
+
if (u.surf.x > 0.0) {
|
|
168
|
+
let grain = triplanar(i.l, N, u.surf.x);
|
|
169
|
+
base = base * (1.0 - u.surf.y * 0.5 + u.surf.y * grain);
|
|
170
|
+
}
|
|
171
|
+
base = hsvShift(base, u.grade.x, max(u.grade.y, 0.0), max(u.grade.z, 0.0));
|
|
172
|
+
let spec = u.surf.z;
|
|
173
|
+
let lit = base * (u.amb.xyz * ff + d * u.sunCol.xyz * sh * (1.0 + spec * 0.6))
|
|
174
|
+
+ base * u.surf.w; // emissive — bioluminescence on dark worlds
|
|
49
175
|
let dist = length(i.world - u.cam.xyz);
|
|
50
176
|
let f = clamp(1.0 - exp(-dist * u.fog.w), 0.0, 1.0);
|
|
51
177
|
return vec4(mix(lit, u.fog.xyz, f), 1.0);
|
|
52
178
|
}`;
|
|
53
179
|
// Depth-only occluder pass: instanced prop depth from the sun's ortho view (so trees/rocks cast shadows).
|
|
180
|
+
// Depth-only occluder. It MUST apply the identical sway, or the shadows stay put while the leaves
|
|
181
|
+
// move — a very visible detachment.
|
|
54
182
|
const OCCLUDER = /* wgsl */ `
|
|
55
|
-
|
|
56
|
-
@group(0) @binding(
|
|
183
|
+
struct OU { lightVP: mat4x4<f32>, wind: vec4<f32> };
|
|
184
|
+
@group(0) @binding(0) var<uniform> o: OU;
|
|
185
|
+
struct Inst { m: mat4x4<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32> };
|
|
186
|
+
@group(0) @binding(1) var<storage,read> models: array<Inst>;
|
|
187
|
+
// The occluder must apply the identical DEFORM as well as the identical sway. A deformed plant whose
|
|
188
|
+
// shadow is cast from its undeformed silhouette is the same detachment bug as swaying leaves over a
|
|
189
|
+
// still shadow, and just as visible.
|
|
190
|
+
fn deformLocal(p: vec3<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32>) -> vec3<f32> {
|
|
191
|
+
let H = 1.0 / max(d0.w, 1e-6);
|
|
192
|
+
let u = clamp(p.y * d0.w, 0.0, 1.0);
|
|
193
|
+
var x = p.x; var y = p.y; var z = p.z;
|
|
194
|
+
let rs = d0.y * (1.0 + d0.z * (1.0 - 2.0 * u));
|
|
195
|
+
x = x * rs; z = z * rs;
|
|
196
|
+
let a = d1.z * u;
|
|
197
|
+
let ca = cos(a); let sa = sin(a);
|
|
198
|
+
let tx = x * ca - z * sa; let tz = x * sa + z * ca;
|
|
199
|
+
x = tx; z = tz;
|
|
200
|
+
y = y * d0.x;
|
|
201
|
+
if (d1.w != 0.0) {
|
|
202
|
+
let r = sqrt(x * x + z * z) * d2.z;
|
|
203
|
+
y = y - d1.w * H * d0.x * r * r * u;
|
|
204
|
+
}
|
|
205
|
+
let dd = d1.x * H * d0.x * u * u;
|
|
206
|
+
x = x + cos(d1.y) * dd;
|
|
207
|
+
z = z + sin(d1.y) * dd;
|
|
208
|
+
let cy = cos(d2.x); let sy = sin(d2.x);
|
|
209
|
+
return vec3(x * cy - z * sy, y, x * sy + z * cy);
|
|
210
|
+
}
|
|
57
211
|
@vertex fn vs(@location(0) p: vec3<f32>, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
|
|
58
|
-
|
|
212
|
+
let inst = models[ii];
|
|
213
|
+
let m = inst.m;
|
|
214
|
+
let dp = deformLocal(p, inst.d0, inst.d1, inst.d2);
|
|
215
|
+
var world = (m * vec4(dp, 1.0)).xyz;
|
|
216
|
+
if (o.wind.z > 0.0001) {
|
|
217
|
+
let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
|
|
218
|
+
let h = max(dp.y, 0.0);
|
|
219
|
+
let stiff = pow(min(h * 0.22, 1.0), 1.6);
|
|
220
|
+
let ph = o.wind.w + dot(world.xz, vec2(0.13, 0.17));
|
|
221
|
+
let a = o.wind.z * stiff * length(m[0].xyz);
|
|
222
|
+
var wd = vec3(o.wind.x, 0.0, o.wind.y);
|
|
223
|
+
wd = normalize(wd - upv * dot(wd, upv));
|
|
224
|
+
world = world + wd * (a * (sin(ph) + 0.35 * sin(ph * 2.7 + h)));
|
|
225
|
+
}
|
|
226
|
+
return o.lightVP * vec4(world, 1.0);
|
|
59
227
|
}`;
|
|
228
|
+
/** Floats per instance in the storage buffer: mat4 + d0 + d1 + d2. */
|
|
229
|
+
const INST_FLOATS = 28;
|
|
60
230
|
export class GpuPropsRenderer {
|
|
61
231
|
device;
|
|
62
232
|
pipe;
|
|
@@ -65,6 +235,8 @@ export class GpuPropsRenderer {
|
|
|
65
235
|
lightBuf;
|
|
66
236
|
models = [];
|
|
67
237
|
instBufs = [];
|
|
238
|
+
/** Allocated capacity in instances, so a per-frame LOD re-bucket reuses the buffer. */
|
|
239
|
+
caps = [];
|
|
68
240
|
bgs = [];
|
|
69
241
|
occBGs = [];
|
|
70
242
|
counts = [];
|
|
@@ -75,6 +247,32 @@ export class GpuPropsRenderer {
|
|
|
75
247
|
shadowBG = null;
|
|
76
248
|
lastSV = null;
|
|
77
249
|
lastSS = null;
|
|
250
|
+
/** Per-world look + motion. Defaults are inert: no sway, no grain, no grade. */
|
|
251
|
+
world = {
|
|
252
|
+
windDir: [1, 0],
|
|
253
|
+
windAmp: 0,
|
|
254
|
+
grainScale: 0,
|
|
255
|
+
grainDepth: 0,
|
|
256
|
+
roughness: 0,
|
|
257
|
+
emissive: 0,
|
|
258
|
+
hueShift: 0,
|
|
259
|
+
saturation: 1,
|
|
260
|
+
valueLift: 1,
|
|
261
|
+
};
|
|
262
|
+
phase = 0;
|
|
263
|
+
/**
|
|
264
|
+
* Set the per-world look and motion. This is the whole point of the procedural surface: the same
|
|
265
|
+
* geometry and the same shader render a different planet's flora by moving a handful of numbers —
|
|
266
|
+
* no per-model textures to author, download, or keep coherent.
|
|
267
|
+
*/
|
|
268
|
+
setWorld(w) {
|
|
269
|
+
this.world = { ...this.world, ...w };
|
|
270
|
+
}
|
|
271
|
+
/** Advance the wind phase. Pass the frame delta; sway is skipped entirely when windAmp is 0. */
|
|
272
|
+
tick(dt) {
|
|
273
|
+
if (this.world.windAmp > 0)
|
|
274
|
+
this.phase += dt * (0.8 + this.world.windAmp * 2.4);
|
|
275
|
+
}
|
|
78
276
|
constructor(device, format) {
|
|
79
277
|
this.device = device;
|
|
80
278
|
const sm = device.createShaderModule({ code: SHADER });
|
|
@@ -127,11 +325,11 @@ export class GpuPropsRenderer {
|
|
|
127
325
|
},
|
|
128
326
|
});
|
|
129
327
|
this.uBuf = device.createBuffer({
|
|
130
|
-
size: 128 + 16 *
|
|
328
|
+
size: 128 + 16 * 9, // 2× mat4 (mvp + lightVP) + 9× vec4 (…, wind, surf, grade)
|
|
131
329
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
132
330
|
});
|
|
133
331
|
this.lightBuf = device.createBuffer({
|
|
134
|
-
size: 64,
|
|
332
|
+
size: 64 + 16, // lightVP + wind (the occluder sways identically or shadows detach)
|
|
135
333
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
136
334
|
});
|
|
137
335
|
const dummy = device.createTexture({
|
|
@@ -161,59 +359,89 @@ export class GpuPropsRenderer {
|
|
|
161
359
|
colB: mk(m.colors, U.VERTEX | U.COPY_DST),
|
|
162
360
|
idxB: mk(m.indices, U.INDEX | U.COPY_DST),
|
|
163
361
|
idxCount: m.indices.length,
|
|
362
|
+
extent: measureExtent(m.positions),
|
|
164
363
|
}));
|
|
364
|
+
this.caps = meshes.map(() => 0);
|
|
165
365
|
this.instBufs = meshes.map(() => null);
|
|
166
366
|
this.bgs = meshes.map(() => null);
|
|
167
367
|
this.occBGs = meshes.map(() => null);
|
|
168
368
|
this.counts = meshes.map(() => 0);
|
|
169
369
|
}
|
|
170
|
-
/**
|
|
370
|
+
/**
|
|
371
|
+
* Group instances by model and upload the per-model instance buffers.
|
|
372
|
+
*
|
|
373
|
+
* Safe to call every frame: buffers are REUSED whenever the new count fits the allocated capacity,
|
|
374
|
+
* and capacity grows with headroom. That matters because distance-bucketed LOD means an instance
|
|
375
|
+
* changes which model it belongs to as the camera moves, so the grouping is not a one-time setup —
|
|
376
|
+
* re-allocating a megabyte of storage buffers per frame would cost more than the LOD saves.
|
|
377
|
+
*/
|
|
171
378
|
setInstances(instances) {
|
|
172
379
|
const dev = this.device;
|
|
173
|
-
const
|
|
174
|
-
const
|
|
380
|
+
const n = this.models.length;
|
|
381
|
+
const counts = new Uint32Array(n);
|
|
382
|
+
for (const it of instances)
|
|
383
|
+
if (it.model >= 0 && it.model < n)
|
|
384
|
+
counts[it.model]++;
|
|
385
|
+
const cursors = new Uint32Array(n);
|
|
386
|
+
const arrays = new Array(n);
|
|
387
|
+
for (let m = 0; m < n; m++)
|
|
388
|
+
arrays[m] = counts[m] ? new Float32Array(counts[m] * INST_FLOATS) : null;
|
|
175
389
|
for (const it of instances) {
|
|
176
|
-
if (it.model < 0 || it.model >=
|
|
390
|
+
if (it.model < 0 || it.model >= n)
|
|
177
391
|
continue;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
392
|
+
const arr = arrays[it.model];
|
|
393
|
+
const o = cursors[it.model]++ * INST_FLOATS;
|
|
394
|
+
arr.set(it.mat.subarray(0, 16), o);
|
|
395
|
+
writeDeform(arr, o + 16, it.deform, this.models[it.model].extent);
|
|
181
396
|
}
|
|
182
|
-
for (let m = 0; m <
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
this.instBufs[m]
|
|
188
|
-
|
|
189
|
-
|
|
397
|
+
for (let m = 0; m < n; m++) {
|
|
398
|
+
const arr = arrays[m];
|
|
399
|
+
this.counts[m] = counts[m];
|
|
400
|
+
if (!arr) {
|
|
401
|
+
// keep the buffer allocated — an empty LOD bucket this frame is usually full the next one
|
|
402
|
+
if (!this.instBufs[m]) {
|
|
403
|
+
this.bgs[m] = null;
|
|
404
|
+
this.occBGs[m] = null;
|
|
405
|
+
}
|
|
190
406
|
continue;
|
|
191
407
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
408
|
+
if (!this.instBufs[m] || counts[m] > this.caps[m]) {
|
|
409
|
+
this.instBufs[m]?.destroy();
|
|
410
|
+
const cap = Math.max(16, Math.ceil(counts[m] * 1.5));
|
|
411
|
+
this.instBufs[m] = dev.createBuffer({
|
|
412
|
+
size: cap * INST_FLOATS * 4,
|
|
413
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
414
|
+
});
|
|
415
|
+
this.caps[m] = cap;
|
|
416
|
+
this.bgs[m] = dev.createBindGroup({
|
|
417
|
+
layout: this.pipe.getBindGroupLayout(0),
|
|
418
|
+
entries: [
|
|
419
|
+
{ binding: 0, resource: { buffer: this.uBuf } },
|
|
420
|
+
{ binding: 1, resource: { buffer: this.instBufs[m] } },
|
|
421
|
+
],
|
|
422
|
+
});
|
|
423
|
+
this.occBGs[m] = dev.createBindGroup({
|
|
424
|
+
layout: this.occPipe.getBindGroupLayout(0),
|
|
425
|
+
entries: [
|
|
426
|
+
{ binding: 0, resource: { buffer: this.lightBuf } },
|
|
427
|
+
{ binding: 1, resource: { buffer: this.instBufs[m] } },
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
dev.queue.writeBuffer(this.instBufs[m], 0, arr);
|
|
212
432
|
}
|
|
213
433
|
}
|
|
214
434
|
/** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
|
|
215
435
|
shadowPass(pass, lightVP) {
|
|
216
|
-
|
|
436
|
+
const ob = new Float32Array(20);
|
|
437
|
+
ob.set(lightVP, 0);
|
|
438
|
+
ob.set([
|
|
439
|
+
this.world.windDir[0],
|
|
440
|
+
this.world.windDir[1],
|
|
441
|
+
this.world.windAmp,
|
|
442
|
+
this.phase,
|
|
443
|
+
], 16);
|
|
444
|
+
this.device.queue.writeBuffer(this.lightBuf, 0, ob);
|
|
217
445
|
pass.setPipeline(this.occPipe);
|
|
218
446
|
for (let m = 0; m < this.models.length; m++) {
|
|
219
447
|
const bg = this.occBGs[m], mdl = this.models[m];
|
|
@@ -230,7 +458,7 @@ export class GpuPropsRenderer {
|
|
|
230
458
|
// in the same shadow. Omit all three (the demo path) → no shadowing, byte-identical to before.
|
|
231
459
|
lightVP, shadowView, shadowSamp) {
|
|
232
460
|
const receive = !!(lightVP && shadowView && shadowSamp);
|
|
233
|
-
const u = new Float32Array(
|
|
461
|
+
const u = new Float32Array(68);
|
|
234
462
|
u.set(camVP, 0);
|
|
235
463
|
u.set(lightVP ?? IDENTITY4, 16); // lightVP (identity when not receiving — result is gated off by sun.w)
|
|
236
464
|
u.set([sunDir[0], sunDir[1], sunDir[2], receive ? 1 : 0], 32); // sun.w = shadow-receive enable
|
|
@@ -240,6 +468,12 @@ export class GpuPropsRenderer {
|
|
|
240
468
|
u.set([fog[0], fog[1], fog[2], fogDensity], 48);
|
|
241
469
|
if (up)
|
|
242
470
|
u.set([up[0], up[1], up[2], 1], 52); // up.w=1 → enable the fixed directional fill (matches the terrain)
|
|
471
|
+
// wind / surface / grade — all default to OFF, so a caller that never calls setWorld() renders
|
|
472
|
+
// byte-identically to before this feature existed.
|
|
473
|
+
const w = this.world;
|
|
474
|
+
u.set([w.windDir[0], w.windDir[1], w.windAmp, this.phase], 56);
|
|
475
|
+
u.set([w.grainScale, w.grainDepth, w.roughness, w.emissive], 60);
|
|
476
|
+
u.set([w.hueShift, w.saturation, w.valueLift, 0], 64);
|
|
243
477
|
this.device.queue.writeBuffer(this.uBuf, 0, u);
|
|
244
478
|
// group 1: the shadow map + comparison sampler (dummy when not receiving). Rebuild only when the view changes.
|
|
245
479
|
const sv = shadowView ?? this.dummyShadowView, ss = shadowSamp ?? this.dummyShadowSamp;
|
|
@@ -278,6 +512,44 @@ export class GpuPropsRenderer {
|
|
|
278
512
|
rp.end();
|
|
279
513
|
}
|
|
280
514
|
}
|
|
515
|
+
/** Source height and half-width of a model, reciprocated so the shader multiplies rather than divides. */
|
|
516
|
+
function measureExtent(pos) {
|
|
517
|
+
let maxY = -Infinity;
|
|
518
|
+
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
519
|
+
for (let v = 0; v + 2 < pos.length; v += 3) {
|
|
520
|
+
if (pos[v + 1] > maxY)
|
|
521
|
+
maxY = pos[v + 1];
|
|
522
|
+
if (pos[v] < minX)
|
|
523
|
+
minX = pos[v];
|
|
524
|
+
if (pos[v] > maxX)
|
|
525
|
+
maxX = pos[v];
|
|
526
|
+
if (pos[v + 2] < minZ)
|
|
527
|
+
minZ = pos[v + 2];
|
|
528
|
+
if (pos[v + 2] > maxZ)
|
|
529
|
+
maxZ = pos[v + 2];
|
|
530
|
+
}
|
|
531
|
+
// The cook normalises every prop to base-at-y=0, so maxY IS the height. A model that violates that
|
|
532
|
+
// still renders — it just gets a `u` ramp that does not reach 1, i.e. a milder deform, never a
|
|
533
|
+
// broken one.
|
|
534
|
+
const h = Math.max(1e-6, maxY);
|
|
535
|
+
const halfW = Math.max(1e-6, Math.max(maxX - minX, maxZ - minZ) * 0.5);
|
|
536
|
+
return { invH: 1 / h, invHalfW: 1 / halfW };
|
|
537
|
+
}
|
|
538
|
+
/** Pack a PropDeform into d0/d1/d2. An absent deform writes the exact identity. */
|
|
539
|
+
function writeDeform(out, o, d, e) {
|
|
540
|
+
out[o] = d?.height ?? 1;
|
|
541
|
+
out[o + 1] = d?.girth ?? 1;
|
|
542
|
+
out[o + 2] = d?.taper ?? 0;
|
|
543
|
+
out[o + 3] = e.invH;
|
|
544
|
+
out[o + 4] = d?.bend ?? 0;
|
|
545
|
+
out[o + 5] = d?.bendDir ?? 0;
|
|
546
|
+
out[o + 6] = d?.twist ?? 0;
|
|
547
|
+
out[o + 7] = d?.droop ?? 0;
|
|
548
|
+
out[o + 8] = d?.yaw ?? 0;
|
|
549
|
+
out[o + 9] = d?.valueJitter ?? 1;
|
|
550
|
+
out[o + 10] = e.invHalfW;
|
|
551
|
+
out[o + 11] = 0;
|
|
552
|
+
}
|
|
281
553
|
// column-major identity mat4, used for lightVP when shadow-receive is off
|
|
282
554
|
const IDENTITY4 = new Float32Array([
|
|
283
555
|
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
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 };
|
|
@@ -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
|
});
|