reze-engine 0.42.3 → 0.43.0
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/README.md +29 -1
- package/dist/engine.d.ts +107 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +741 -23
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +29 -0
- package/dist/shaders/audio-api.d.ts +3 -0
- package/dist/shaders/audio-api.d.ts.map +1 -0
- package/dist/shaders/audio-api.js +81 -0
- package/dist/shaders/passes/composite.d.ts +10 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +75 -12
- package/dist/shaders/passes/particles.d.ts +58 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -0
- package/dist/shaders/passes/particles.js +351 -0
- package/dist/shaders/passes/trails.d.ts +47 -0
- package/dist/shaders/passes/trails.d.ts.map +1 -0
- package/dist/shaders/passes/trails.js +375 -0
- package/package.json +1 -1
- package/src/engine.ts +800 -20
- package/src/model.ts +28 -0
- package/src/shaders/audio-api.ts +82 -0
- package/src/shaders/passes/composite.ts +76 -11
- package/src/shaders/passes/particles.ts +398 -0
- package/src/shaders/passes/trails.ts +406 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { audioApi } from "../audio-api";
|
|
2
|
+
/**
|
|
3
|
+
* The trail accessors, in the PARTICLE module.
|
|
4
|
+
*
|
|
5
|
+
* Sparks are the reason: the original hand ribbon shed sparks along its path,
|
|
6
|
+
* and as real particles they need to SPAWN on that path — which means
|
|
7
|
+
* particleInit reading the same recorded history the trail draws from. One
|
|
8
|
+
* effect file, two mounts, one buffer.
|
|
9
|
+
*/
|
|
10
|
+
function castApi(cast) {
|
|
11
|
+
return `
|
|
12
|
+
const RZ_SUBJECTS: i32 = ${cast.subjects};
|
|
13
|
+
const RZ_SAMPLES: i32 = ${cast.samples};
|
|
14
|
+
const RZ_TRAIL_SLOTS: i32 = ${cast.slots};
|
|
15
|
+
fn rzSubjectCount() -> i32 {
|
|
16
|
+
var n = 0;
|
|
17
|
+
for (var i = 0; i < RZ_SUBJECTS; i++) {
|
|
18
|
+
if (_rzCast[i * 3 + 2].w > 0.0) { n = i + 1; }
|
|
19
|
+
}
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
fn rzTrailCount(subject: i32, slot: i32) -> i32 {
|
|
23
|
+
if (subject < 0 || subject >= RZ_SUBJECTS || slot < 0 || slot >= RZ_TRAIL_SLOTS) { return 0; }
|
|
24
|
+
return i32(_rzCast[${cast.base} + (slot * RZ_SUBJECTS + subject) * 3 + 2].w);
|
|
25
|
+
}
|
|
26
|
+
/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
|
|
27
|
+
fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
|
|
28
|
+
let n = rzTrailCount(subject, slot);
|
|
29
|
+
if (i < 0 || i >= n) { return vec4f(0.0); }
|
|
30
|
+
return _rzCast[${cast.trailBase} + (slot * RZ_SUBJECTS + subject) * RZ_SAMPLES + i];
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
/** Bytes per particle. Explicitly padded — see the struct below. */
|
|
35
|
+
export const PARTICLE_STRIDE = 48;
|
|
36
|
+
/**
|
|
37
|
+
* The particle record, laid out by hand.
|
|
38
|
+
*
|
|
39
|
+
* `age` and `life` sit in the padding that vec3f alignment would waste anyway
|
|
40
|
+
* (a vec3f occupies 12 bytes but aligns the next field to 16), so the struct is
|
|
41
|
+
* 48 bytes rather than the 64 a naive ordering costs. At 4096 particles that is
|
|
42
|
+
* 192KB instead of 256KB, and it is read every frame by both stages.
|
|
43
|
+
*
|
|
44
|
+
* `life <= 0` means "not alive" and is what the pool checks to recycle a slot,
|
|
45
|
+
* so a freshly zeroed buffer is entirely dead and every particle is born on the
|
|
46
|
+
* first step rather than needing a separate seeding pass.
|
|
47
|
+
*/
|
|
48
|
+
const PARTICLE_STRUCT = /* wgsl */ `
|
|
49
|
+
struct Particle {
|
|
50
|
+
pos: vec3f,
|
|
51
|
+
age: f32,
|
|
52
|
+
vel: vec3f,
|
|
53
|
+
life: f32,
|
|
54
|
+
size: f32,
|
|
55
|
+
rot: f32,
|
|
56
|
+
seed: f32,
|
|
57
|
+
// Aspect along the direction of travel. 1 or less is a square billboard; a
|
|
58
|
+
// raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets
|
|
59
|
+
// the square it expects.
|
|
60
|
+
stretch: f32,
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
const CAMERA_STRUCT = /* wgsl */ `
|
|
64
|
+
struct CameraU {
|
|
65
|
+
view: mat4x4f,
|
|
66
|
+
proj: mat4x4f,
|
|
67
|
+
camPos: vec3f,
|
|
68
|
+
targetHeight: f32,
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
const PARTICLE_UNIFORMS = /* wgsl */ `
|
|
72
|
+
struct ParticleU {
|
|
73
|
+
time: f32,
|
|
74
|
+
dt: f32,
|
|
75
|
+
count: u32,
|
|
76
|
+
frame: u32,
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
/**
|
|
80
|
+
* The shared prelude, everything `rz`-prefixed.
|
|
81
|
+
*
|
|
82
|
+
* Not convenience — correctness. Every effect written against the old contract
|
|
83
|
+
* re-derived its own hash and its own falloff, which is duplicated code and
|
|
84
|
+
* duplicated bugs; `rzFalloff` in particular has COMPACT SUPPORT (it reaches
|
|
85
|
+
* exactly zero at r), because an exponential glow that never quite reaches zero
|
|
86
|
+
* has to be culled somewhere, and culling it wherever it "looks close enough"
|
|
87
|
+
* is what put a visible hard edge on the first halo effect.
|
|
88
|
+
*/
|
|
89
|
+
const PRELUDE = /* wgsl */ `
|
|
90
|
+
fn rzHash11(x: f32) -> f32 {
|
|
91
|
+
var p = fract(x * 0.1031);
|
|
92
|
+
p = p * (p + 33.33);
|
|
93
|
+
return fract(p * (p + p));
|
|
94
|
+
}
|
|
95
|
+
fn rzHash21(p: vec2f) -> f32 {
|
|
96
|
+
var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
|
|
97
|
+
p3 = p3 + dot(p3, p3.yzx + 33.33);
|
|
98
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
99
|
+
}
|
|
100
|
+
fn rzHash31(p: vec3f) -> f32 {
|
|
101
|
+
var p3 = fract(p * 0.1031);
|
|
102
|
+
p3 = p3 + dot(p3, p3.zyx + 31.32);
|
|
103
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
104
|
+
}
|
|
105
|
+
/** Three independent randoms from one seed — the usual need when spawning. */
|
|
106
|
+
fn rzHash13(x: f32) -> vec3f {
|
|
107
|
+
return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
|
|
108
|
+
}
|
|
109
|
+
fn rzValueNoise(p: vec3f) -> f32 {
|
|
110
|
+
let i = floor(p);
|
|
111
|
+
let f = fract(p);
|
|
112
|
+
let u = f * f * (3.0 - 2.0 * f);
|
|
113
|
+
let n000 = rzHash31(i + vec3f(0.0, 0.0, 0.0));
|
|
114
|
+
let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
|
|
115
|
+
let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
|
|
116
|
+
let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
|
|
117
|
+
let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
|
|
118
|
+
let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
|
|
119
|
+
let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
|
|
120
|
+
let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
|
|
121
|
+
let x00 = mix(n000, n100, u.x);
|
|
122
|
+
let x10 = mix(n010, n110, u.x);
|
|
123
|
+
let x01 = mix(n001, n101, u.x);
|
|
124
|
+
let x11 = mix(n011, n111, u.x);
|
|
125
|
+
return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
|
|
126
|
+
}
|
|
127
|
+
/** Divergence-free flow — the standard drifting-air force. Snow and mist want this. */
|
|
128
|
+
fn rzCurlNoise(p: vec3f) -> vec3f {
|
|
129
|
+
let e = 0.1;
|
|
130
|
+
let dx = vec3f(e, 0.0, 0.0);
|
|
131
|
+
let dy = vec3f(0.0, e, 0.0);
|
|
132
|
+
let dz = vec3f(0.0, 0.0, e);
|
|
133
|
+
let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);
|
|
134
|
+
let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);
|
|
135
|
+
let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);
|
|
136
|
+
return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));
|
|
137
|
+
}
|
|
138
|
+
/** Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between. */
|
|
139
|
+
fn rzFalloff(d: f32, r: f32) -> f32 {
|
|
140
|
+
let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
|
|
141
|
+
let f = 1.0 - x;
|
|
142
|
+
return f * f * f;
|
|
143
|
+
}
|
|
144
|
+
fn rzTime() -> f32 { return pu.time; }
|
|
145
|
+
fn rzViewportHeight() -> f32 { return cam.targetHeight; }
|
|
146
|
+
fn rzCameraPos() -> vec3f { return cam.camPos; }
|
|
147
|
+
fn rzCameraRight() -> vec3f { return vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]); }
|
|
148
|
+
fn rzCameraUp() -> vec3f { return vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]); }
|
|
149
|
+
fn rzCameraForward() -> vec3f { return vec3f(cam.view[0][2], cam.view[1][2], cam.view[2][2]); }
|
|
150
|
+
/** World point → (uv, view distance), same contract as the field mounts' rzProject. */
|
|
151
|
+
fn rzProject(p: vec3f) -> vec3f {
|
|
152
|
+
let clip = cam.proj * cam.view * vec4f(p, 1.0);
|
|
153
|
+
let w = max(clip.w, 1e-4);
|
|
154
|
+
return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
|
|
155
|
+
}
|
|
156
|
+
fn rzDt() -> f32 { return pu.dt; }
|
|
157
|
+
fn rzCamPos() -> vec3f { return cam.camPos; }
|
|
158
|
+
`;
|
|
159
|
+
/** `// @particles 4096` — how many live at once. */
|
|
160
|
+
export function parseParticleCount(wgsl, max) {
|
|
161
|
+
const m = /^\s*\/\/\s*@particles\s+(\d+)\s*$/m.exec(wgsl);
|
|
162
|
+
if (!m)
|
|
163
|
+
return 0;
|
|
164
|
+
// Clamped rather than rejected: an author asking for a million gets the most
|
|
165
|
+
// the engine will give and a scene that still runs, which is a better failure
|
|
166
|
+
// than a compile error naming a number they had no way to know.
|
|
167
|
+
return Math.max(1, Math.min(max, parseInt(m[1], 10)));
|
|
168
|
+
}
|
|
169
|
+
/** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
|
|
170
|
+
export function parseParticleBloom(wgsl) {
|
|
171
|
+
return /^\s*\/\/\s*@bloom\s*$/m.test(wgsl);
|
|
172
|
+
}
|
|
173
|
+
/** `// @blend additive` — default is straight alpha. */
|
|
174
|
+
export function parseParticleBlend(wgsl) {
|
|
175
|
+
return /^\s*\/\/\s*@blend\s+additive\s*$/m.test(wgsl) ? "additive" : "alpha";
|
|
176
|
+
}
|
|
177
|
+
/** Does the source define the particle contract? All three are required together. */
|
|
178
|
+
export function particleEntryPoints(wgsl) {
|
|
179
|
+
return {
|
|
180
|
+
init: /\bfn\s+particleInit\s*\(/.test(wgsl),
|
|
181
|
+
step: /\bfn\s+particleStep\s*\(/.test(wgsl),
|
|
182
|
+
shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Spawn, age, recycle.
|
|
187
|
+
*
|
|
188
|
+
* One kernel for both birth and update, because a dead slot and a new particle
|
|
189
|
+
* are the same write — a pool that recycles has no allocation and therefore no
|
|
190
|
+
* spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
|
|
191
|
+
* only by whatever the author randomises in `particleInit`, which for rain and
|
|
192
|
+
* snow is exactly right, and for a burst is what the age offset is for.
|
|
193
|
+
*/
|
|
194
|
+
export function buildParticleComputeShader(src, cast) {
|
|
195
|
+
return (PARTICLE_STRUCT +
|
|
196
|
+
CAMERA_STRUCT +
|
|
197
|
+
PARTICLE_UNIFORMS +
|
|
198
|
+
`
|
|
199
|
+
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
|
|
200
|
+
@group(0) @binding(1) var<uniform> pu: ParticleU;
|
|
201
|
+
@group(0) @binding(2) var<uniform> cam: CameraU;
|
|
202
|
+
@group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
|
|
203
|
+
` +
|
|
204
|
+
castApi(cast) +
|
|
205
|
+
audioApi(0, 4) +
|
|
206
|
+
PRELUDE +
|
|
207
|
+
"\n// ── user effect ──\n" +
|
|
208
|
+
src.wgsl +
|
|
209
|
+
/* wgsl */ `
|
|
210
|
+
@compute @workgroup_size(64)
|
|
211
|
+
fn main(@builtin(global_invocation_id) gid: vec3u) {
|
|
212
|
+
let i = gid.x;
|
|
213
|
+
if (i >= pu.count) { return; }
|
|
214
|
+
var p = particles[i];
|
|
215
|
+
if (p.life <= 0.0 || p.age >= p.life) {
|
|
216
|
+
// The seed is stable per SLOT and per generation, so a particle looks the
|
|
217
|
+
// same every time the scene is replayed at the same moment — which is what
|
|
218
|
+
// keeps an exported video identical to the preview.
|
|
219
|
+
let generation = floor(pu.time * 0.37) + f32(i) * 0.618;
|
|
220
|
+
p = particleInit(i, rzHash11(generation));
|
|
221
|
+
// age is NOT reset here. WGSL zero-initialises a var, so an author who
|
|
222
|
+
// ignores it starts at zero anyway — while one who sets it to a fraction of
|
|
223
|
+
// its life staggers the pool, which is the difference between snow and a
|
|
224
|
+
// pulse of snow arriving all at once every few seconds.
|
|
225
|
+
} else {
|
|
226
|
+
p = particleStep(p, pu.dt);
|
|
227
|
+
p.age = p.age + pu.dt;
|
|
228
|
+
}
|
|
229
|
+
particles[i] = p;
|
|
230
|
+
}
|
|
231
|
+
`);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* One camera-facing quad per live particle.
|
|
235
|
+
*
|
|
236
|
+
* Six vertices, no index or vertex buffer: the corners are derived from
|
|
237
|
+
* `vertex_index` and the particle is read from storage by `instance_index`, so a
|
|
238
|
+
* draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
|
|
239
|
+
* basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
|
|
240
|
+
* the quad square on screen no matter where the camera rolls.
|
|
241
|
+
*
|
|
242
|
+
* A dead particle collapses to a degenerate quad instead of being culled on the
|
|
243
|
+
* CPU — the alternative is a compacted draw list, which costs a prefix sum and a
|
|
244
|
+
* readback every frame to save vertices the rasteriser was going to reject anyway.
|
|
245
|
+
*/
|
|
246
|
+
export function buildParticleRenderShader(src, cast) {
|
|
247
|
+
return (`override BLOOM: bool = ${src.bloom ? "true" : "false"};
|
|
248
|
+
override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
|
|
249
|
+
PARTICLE_STRUCT +
|
|
250
|
+
CAMERA_STRUCT +
|
|
251
|
+
PARTICLE_UNIFORMS +
|
|
252
|
+
`
|
|
253
|
+
@group(0) @binding(0) var<storage, read> particles: array<Particle>;
|
|
254
|
+
@group(0) @binding(1) var<uniform> pu: ParticleU;
|
|
255
|
+
@group(0) @binding(2) var<uniform> cam: CameraU;
|
|
256
|
+
@group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
|
|
257
|
+
` +
|
|
258
|
+
castApi(cast) +
|
|
259
|
+
audioApi(0, 4) +
|
|
260
|
+
PRELUDE +
|
|
261
|
+
"\n// ── user effect ──\n" +
|
|
262
|
+
src.wgsl +
|
|
263
|
+
/* wgsl */ `
|
|
264
|
+
struct VSOut {
|
|
265
|
+
@builtin(position) clip: vec4f,
|
|
266
|
+
@location(0) uv: vec2f,
|
|
267
|
+
@location(1) @interpolate(flat) id: u32,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@vertex
|
|
271
|
+
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
|
|
272
|
+
var out: VSOut;
|
|
273
|
+
out.id = ii;
|
|
274
|
+
let p = particles[ii];
|
|
275
|
+
// Two triangles, corners in the order 0,1,2, 2,1,3.
|
|
276
|
+
let quad = array<vec2f, 6>(
|
|
277
|
+
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
|
|
278
|
+
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
|
|
279
|
+
);
|
|
280
|
+
let c = quad[vi];
|
|
281
|
+
out.uv = c * 0.5 + 0.5;
|
|
282
|
+
if (p.life <= 0.0) {
|
|
283
|
+
// Degenerate: off the near plane, rasterises nothing.
|
|
284
|
+
out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
let right = vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]);
|
|
288
|
+
let up = vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]);
|
|
289
|
+
let s = sin(p.rot);
|
|
290
|
+
let k = cos(p.rot);
|
|
291
|
+
var r = vec2f(c.x * k - c.y * s, c.x * s + c.y * k);
|
|
292
|
+
// Stretched along the direction of travel ON SCREEN — which is not the world
|
|
293
|
+
// direction once the camera is off-axis. Rain falling straight down is nearly
|
|
294
|
+
// a point when viewed from above and a long streak from the side, and taking
|
|
295
|
+
// the velocity's components in the camera's own basis is what gets both right.
|
|
296
|
+
// Rotation is ignored while stretched: the velocity IS the orientation.
|
|
297
|
+
if (p.stretch > 1.0) {
|
|
298
|
+
let vr = dot(p.vel, right);
|
|
299
|
+
let vu = dot(p.vel, up);
|
|
300
|
+
let vlen = length(vec2f(vr, vu));
|
|
301
|
+
if (vlen > 1e-5) {
|
|
302
|
+
let d = vec2f(vr, vu) / vlen;
|
|
303
|
+
r = vec2f(d.y, -d.x) * c.x + d * (c.y * p.stretch);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
let world = p.pos + (right * r.x + up * r.y) * p.size;
|
|
307
|
+
out.clip = cam.proj * cam.view * vec4f(world, 1.0);
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
struct FSOut {
|
|
312
|
+
@location(0) color: vec4f,
|
|
313
|
+
// The scene's aux target: (bloom mask, coverage). Materials write it, so a
|
|
314
|
+
// particle that skipped it would punch a hole in the mask of whatever it drew
|
|
315
|
+
// over.
|
|
316
|
+
//
|
|
317
|
+
// vec4f even though the target is rg8unorm and only .rg land: that target's
|
|
318
|
+
// blend factors reference SrcAlpha, and a fragment with no alpha channel is
|
|
319
|
+
// rejected outright — "reading alpha but it is missing from fragment output".
|
|
320
|
+
// The material shaders declare vec4f here for the same reason.
|
|
321
|
+
@location(1) mask: vec4f,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@fragment
|
|
325
|
+
fn fs(in: VSOut) -> FSOut {
|
|
326
|
+
let p = particles[in.id];
|
|
327
|
+
let c = particleShade(p, in.uv);
|
|
328
|
+
if (c.a <= 0.0) { discard; }
|
|
329
|
+
var out: FSOut;
|
|
330
|
+
// PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
|
|
331
|
+
// straight-alpha fragment would come out over-bright wherever it is
|
|
332
|
+
// translucent — which is most of a soft particle.
|
|
333
|
+
out.color = vec4f(c.rgb * c.a, c.a);
|
|
334
|
+
// The mask's OPERATOR must match the colour's, or the composite invents bands.
|
|
335
|
+
//
|
|
336
|
+
// The composite divides the HDR colour by this coverage to un-premultiply
|
|
337
|
+
// before tone mapping. An ALPHA effect writes (gate, 1.0) and lets the
|
|
338
|
+
// src-alpha blend make alpha-over coverage, exactly as the materials do. An
|
|
339
|
+
// ADDITIVE effect's pipeline blends this target with factor ONE instead, and
|
|
340
|
+
// writes its values directly — coverage SUMS like the colour does, so
|
|
341
|
+
// Σ(rgb·a)/Σa returns the true colour even where the effect overlaps itself.
|
|
342
|
+
// With summed colour over alpha-over coverage, every self-overlap divided into
|
|
343
|
+
// a bright white bar — visible only over the background, because the model's
|
|
344
|
+
// own coverage is already 1 there and the divide is a no-op. That mismatch,
|
|
345
|
+
// not geometry, was the banding that survived every geometry fix.
|
|
346
|
+
let mg = select(vec2f(select(0.0, 1.0, BLOOM), 1.0), vec2f(select(0.0, c.a, BLOOM), c.a), ADDITIVE);
|
|
347
|
+
out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
`);
|
|
351
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type TrailSource = {
|
|
2
|
+
/** The author's WGSL verbatim. */
|
|
3
|
+
wgsl: string;
|
|
4
|
+
/** Declared anchors with `trail`, in slot order. */
|
|
5
|
+
slots: number;
|
|
6
|
+
/** Additive, like most glowing ribbons, or straight alpha. */
|
|
7
|
+
blend: "alpha" | "additive";
|
|
8
|
+
bloom: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Sub-segments drawn between each pair of recorded samples.
|
|
12
|
+
*
|
|
13
|
+
* The path is sampled at a fixed rate on the scene clock, so a fast hand leaves
|
|
14
|
+
* its samples far apart and a strip drawn straight between them is visibly
|
|
15
|
+
* faceted. Four sub-segments on a Catmull-Rom curve through the neighbours costs
|
|
16
|
+
* four times the vertices — which is nothing, they are vertices — and removes
|
|
17
|
+
* both the faceting and most of the jitter, since a spline tangent varies
|
|
18
|
+
* smoothly where a per-segment direction snaps about whenever the hand slows.
|
|
19
|
+
*/
|
|
20
|
+
export declare const TRAIL_SUBDIVISIONS = 6;
|
|
21
|
+
/** Does the source define the trail contract? Both are required together. */
|
|
22
|
+
export declare function trailEntryPoints(wgsl: string): {
|
|
23
|
+
width: boolean;
|
|
24
|
+
shade: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* One quad per segment, laid out flat across every anchor and character.
|
|
28
|
+
*
|
|
29
|
+
* The instance index encodes all three — segment, subject, slot — so a scene
|
|
30
|
+
* with three dancers and eight declared bones is still ONE draw call and needs
|
|
31
|
+
* nothing computed on the CPU per frame. Instances past the end of a real trail
|
|
32
|
+
* collapse to a degenerate quad, which costs a vertex shader invocation and no
|
|
33
|
+
* fragments; the alternative is a compacted instance list, which costs a
|
|
34
|
+
* readback every frame to save exactly that.
|
|
35
|
+
*
|
|
36
|
+
* The ribbon faces the camera per SEGMENT rather than as a whole: the side
|
|
37
|
+
* vector is the segment direction crossed with the direction to the eye, so a
|
|
38
|
+
* ribbon that loops back on itself stays visible along its entire length instead
|
|
39
|
+
* of vanishing edge-on where it turns.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildTrailShader(src: TrailSource, cast: {
|
|
42
|
+
subjects: number;
|
|
43
|
+
samples: number;
|
|
44
|
+
base: number;
|
|
45
|
+
trailBase: number;
|
|
46
|
+
}): string;
|
|
47
|
+
//# sourceMappingURL=trails.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trails.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/trails.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,WAAW,GAAG;IACxB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,KAAK,EAAE,OAAO,GAAG,UAAU,CAAA;IAC3B,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAKjF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAsVvI"}
|