reze-engine 0.42.2 → 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/camera-animation.d.ts +8 -0
- package/dist/camera-animation.d.ts.map +1 -1
- package/dist/camera-animation.js +10 -0
- package/dist/engine.d.ts +110 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +746 -23
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +29 -0
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -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/ground-noise.d.ts +7 -0
- package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
- package/dist/shaders/passes/ground-noise.js +88 -0
- 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 +2 -2
- package/src/camera-animation.ts +11 -0
- package/src/engine.ts +806 -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
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/materials/body.d.ts +0 -2
- package/dist/shaders/materials/body.d.ts.map +0 -1
- package/dist/shaders/materials/body.js +0 -95
- package/dist/shaders/materials/cloth_rough.d.ts +0 -2
- package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_rough.js +0 -69
- package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
- package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_smooth.js +0 -61
- package/dist/shaders/materials/default.d.ts +0 -2
- package/dist/shaders/materials/default.d.ts.map +0 -1
- package/dist/shaders/materials/default.js +0 -43
- package/dist/shaders/materials/eye.d.ts +0 -2
- package/dist/shaders/materials/eye.d.ts.map +0 -1
- package/dist/shaders/materials/eye.js +0 -60
- package/dist/shaders/materials/face.d.ts +0 -2
- package/dist/shaders/materials/face.d.ts.map +0 -1
- package/dist/shaders/materials/face.js +0 -95
- package/dist/shaders/materials/hair.d.ts +0 -2
- package/dist/shaders/materials/hair.d.ts.map +0 -1
- package/dist/shaders/materials/hair.js +0 -90
- package/dist/shaders/materials/metal.d.ts +0 -2
- package/dist/shaders/materials/metal.d.ts.map +0 -1
- package/dist/shaders/materials/metal.js +0 -77
- package/dist/shaders/materials/mmd_classic.d.ts +0 -2
- package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
- package/dist/shaders/materials/mmd_classic.js +0 -66
- package/dist/shaders/materials/stockings.d.ts +0 -2
- package/dist/shaders/materials/stockings.d.ts.map +0 -1
- package/dist/shaders/materials/stockings.js +0 -122
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- package/dist/shaders/passes/physics-debug.js +0 -69
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { audioApi } from "../audio-api";
|
|
1
2
|
// Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
|
|
2
3
|
// Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
|
|
3
4
|
//
|
|
@@ -148,6 +149,16 @@ override APPLY_GAMMA: bool = true;
|
|
|
148
149
|
// vec4 slots: [0 .. 11] four subjects, three each (root+valid, hip, bounds);
|
|
149
150
|
// then MAX_ANCHORS × four subjects, three each (pos+valid, vel, fwd).
|
|
150
151
|
@group(0) @binding(11) var<storage, read> _rzCast: array<vec4f>;
|
|
152
|
+
// The FIELD LAYER: the user's background/foreground mounts, rendered at half
|
|
153
|
+
// resolution in their own pass (see buildFieldShader) and sampled here. Field
|
|
154
|
+
// effects are glows, fog, shafts, gradients — low-frequency by nature — and
|
|
155
|
+
// running them per quarter-pixel was the single largest per-frame cost an
|
|
156
|
+
// effect could add. Bilinear upsampling is invisible at that frequency.
|
|
157
|
+
@group(0) @binding(15) var fieldBgTex: texture_2d<f32>;
|
|
158
|
+
@group(0) @binding(16) var fieldFgTex: texture_2d<f32>;
|
|
159
|
+
// Geometry ribbons, max-blended in their own layer (trails.ts). Composited in
|
|
160
|
+
// display space below, where the fullscreen ribbon effects always ran.
|
|
161
|
+
@group(0) @binding(12) var trailTex: texture_2d<f32>;
|
|
151
162
|
|
|
152
163
|
// Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
|
|
153
164
|
const FILMIC_LUT_W: f32 = 256.0;
|
|
@@ -288,6 +299,11 @@ fn rzProject(p: vec3f) -> vec3f {
|
|
|
288
299
|
return vec3f(ndc * 0.5 + 0.5, z);
|
|
289
300
|
}
|
|
290
301
|
|
|
302
|
+
fn rzCamPos() -> vec3f { return rzCameraPos(); }
|
|
303
|
+
fn rzCameraRight() -> vec3f { return viewU[3].xyz; }
|
|
304
|
+
fn rzCameraUp() -> vec3f { return viewU[4].xyz; }
|
|
305
|
+
fn rzCameraForward() -> vec3f { return viewU[5].xyz; }
|
|
306
|
+
|
|
291
307
|
/** A character, as much of one as a shader needs. */
|
|
292
308
|
struct RzSubject {
|
|
293
309
|
/** On the FLOOR, under the body — where a ring or a magic circle belongs. */
|
|
@@ -562,6 +578,11 @@ const COMPOSITE_BODY = /* wgsl */ `
|
|
|
562
578
|
// expression, because the foreground mount composites onto it.
|
|
563
579
|
var outRgb = disp * sceneAlpha + bgPm * (1.0 - sceneAlpha);
|
|
564
580
|
var outA = sceneAlpha + bgA * (1.0 - sceneAlpha);
|
|
581
|
+
// The trail layer, straight-alpha over the tone-mapped frame — the author's
|
|
582
|
+
// colours verbatim, never through AgX.
|
|
583
|
+
let trailFx = textureLoad(trailTex, vec2i(fragCoord.xy), 0);
|
|
584
|
+
outRgb = trailFx.rgb * trailFx.a + outRgb * (1.0 - trailFx.a);
|
|
585
|
+
outA = trailFx.a + outA * (1.0 - trailFx.a);
|
|
565
586
|
FOREGROUND_CALL
|
|
566
587
|
return vec4f(outRgb, outA);
|
|
567
588
|
}
|
|
@@ -571,8 +592,7 @@ const COMPOSITE_BODY = /* wgsl */ `
|
|
|
571
592
|
// OVER onto the base layer. No `if` around it: the pipeline is rebuilt per
|
|
572
593
|
// effect, so this text only exists in variants whose WGSL defines background().
|
|
573
594
|
const BACKGROUND_CALL = /* wgsl */ `
|
|
574
|
-
let
|
|
575
|
-
let bgFx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
|
|
595
|
+
let bgFx = clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0));
|
|
576
596
|
bgPm = bgFx.rgb * bgFx.a + bgPm * (1.0 - bgFx.a);
|
|
577
597
|
bgA = bgFx.a + bgA * (1.0 - bgFx.a);
|
|
578
598
|
`;
|
|
@@ -580,12 +600,7 @@ const BACKGROUND_CALL = /* wgsl */ `
|
|
|
580
600
|
// base. Ungated by design: a foreground runs at every pixel, including the ones
|
|
581
601
|
// the model covers, because covering them is the point.
|
|
582
602
|
const FOREGROUND_CALL = /* wgsl */ `
|
|
583
|
-
let
|
|
584
|
-
// The scene's own depth, so the effect can tell what is in front of it: a
|
|
585
|
-
// petal compares its distance against this and lets the model take the pixel,
|
|
586
|
-
// and fog's alpha is nothing but a function of it. Pixels the scene never drew
|
|
587
|
-
// read the far plane, so distance fog closes over the backdrop too.
|
|
588
|
-
let fgFx = clamp(foreground(dir, fgUv, viewU[6].x, linearDepth(coord)), vec4f(0.0), vec4f(1.0));
|
|
603
|
+
let fgFx = clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0));
|
|
589
604
|
outRgb = fgFx.rgb * fgFx.a + outRgb * (1.0 - fgFx.a);
|
|
590
605
|
outA = fgFx.a + outA * (1.0 - fgFx.a);
|
|
591
606
|
`;
|
|
@@ -610,24 +625,72 @@ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/;
|
|
|
610
625
|
function backgroundCondition(effect) {
|
|
611
626
|
// sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
|
|
612
627
|
// sharp scene fully covered can end up needing background behind its blur.
|
|
628
|
+
// (The old derivative carve-out is gone with the inline user code: the field
|
|
629
|
+
// pass runs the whole quad, which is uniform control flow by construction.)
|
|
613
630
|
const coverage = "sceneAlpha < 0.999";
|
|
614
631
|
if (!effect?.hasBackground)
|
|
615
632
|
return `bg.w > 1.5 && ${coverage}`;
|
|
616
|
-
return
|
|
633
|
+
return coverage;
|
|
617
634
|
}
|
|
618
635
|
export function buildCompositeShader(effect) {
|
|
619
636
|
const body = COMPOSITE_BODY.replace("BACKGROUND_COND", backgroundCondition(effect))
|
|
620
637
|
.replace("BACKGROUND_CALL", effect?.hasBackground ? BACKGROUND_CALL.trim() : "")
|
|
621
638
|
.replace("FOREGROUND_CALL", effect?.hasForeground ? FOREGROUND_CALL.trim() : "");
|
|
622
|
-
|
|
623
|
-
|
|
639
|
+
// The composite is STATIC either way now: the user's code compiles in the
|
|
640
|
+
// field module alone, and the composite only decides whether to sample it.
|
|
641
|
+
return COMPOSITE_HEAD + audioApi(0, 13) + body;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* The field pass: the user's background/foreground mounts at half resolution,
|
|
645
|
+
* into two rgba16f targets the composite bilinearly upsamples.
|
|
646
|
+
*
|
|
647
|
+
* The fragment reconstructs the FULL-resolution pixel it stands in for and runs
|
|
648
|
+
* the original derivation verbatim — same ndc, same ray, same uv, same depth
|
|
649
|
+
* read — so an effect cannot tell it moved; it is simply asked half as often
|
|
650
|
+
* in each direction.
|
|
651
|
+
*/
|
|
652
|
+
export function buildFieldShader(effect) {
|
|
653
|
+
const bgLine = effect.hasBackground
|
|
654
|
+
? "out.bg = clamp(background(dir, uv, viewU[6].x), vec4f(0.0), vec4f(1.0));"
|
|
655
|
+
: "";
|
|
656
|
+
const fgLine = effect.hasForeground
|
|
657
|
+
? "out.fg = clamp(foreground(dir, uv, viewU[6].x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
|
|
658
|
+
: "";
|
|
624
659
|
return (COMPOSITE_HEAD +
|
|
660
|
+
audioApi(0, 13) +
|
|
625
661
|
"\n// ── user effect (setEffect) ──\n" +
|
|
626
662
|
effect.paramsDecl +
|
|
627
663
|
"\n" +
|
|
628
664
|
effect.wgsl +
|
|
629
665
|
"\n" +
|
|
630
|
-
|
|
666
|
+
/* wgsl */ `
|
|
667
|
+
@group(0) @binding(14) var<uniform> fieldU: vec4f;
|
|
668
|
+
|
|
669
|
+
@vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
670
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
671
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
672
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
struct FieldOut {
|
|
676
|
+
@location(0) bg: vec4f,
|
|
677
|
+
@location(1) fg: vec4f,
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
@fragment fn fieldFs(@builtin(position) fragCoord: vec4f) -> FieldOut {
|
|
681
|
+
let fullSz = fieldU.zw;
|
|
682
|
+
let fx = fragCoord.xy * (fullSz / max(fieldU.xy, vec2f(1.0)));
|
|
683
|
+
let ndc = vec2f(fx.x / fullSz.x * 2.0 - 1.0, 1.0 - fx.y / fullSz.y * 2.0);
|
|
684
|
+
let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
|
|
685
|
+
let uv = vec2f(fx.x / fullSz.x, 1.0 - fx.y / fullSz.y);
|
|
686
|
+
var out: FieldOut;
|
|
687
|
+
out.bg = vec4f(0.0);
|
|
688
|
+
out.fg = vec4f(0.0);
|
|
689
|
+
${bgLine}
|
|
690
|
+
${fgLine}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
`);
|
|
631
694
|
}
|
|
632
695
|
/** Kept for compatibility with existing imports (the base, no-effect shader). */
|
|
633
696
|
export const COMPOSITE_SHADER_WGSL = buildCompositeShader(null);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tile as 8-bit luminance, plus its mip chain (box-filtered, which is exact
|
|
3
|
+
* for a repeating texture). Mips are the point as much as the speed: procedural
|
|
4
|
+
* noise has no mip chain, so at grazing angles it shimmered.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildGroundNoiseMips(): Uint8Array[];
|
|
7
|
+
//# sourceMappingURL=ground-noise.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ground-noise.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground-noise.ts"],"names":[],"mappings":"AAkDA;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,EAAE,CAsCnD"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Bakes the ground's frosted micro-texture into a seamless, mipmapped tile.
|
|
2
|
+
//
|
|
3
|
+
// The shader used to evaluate four octaves of value noise per pixel — sixteen
|
|
4
|
+
// hash rounds — and the ground covers the entire screen as soon as the camera
|
|
5
|
+
// tilts toward the horizon. This produces the same character of pattern once, at
|
|
6
|
+
// load, so the fragment shader pays one filtered fetch instead.
|
|
7
|
+
//
|
|
8
|
+
// SEAMLESS BY CONSTRUCTION. The original hash was not periodic, so a finite
|
|
9
|
+
// crop of it could never tile. Here every octave's lattice wraps at a whole
|
|
10
|
+
// number of cells across the tile, which makes the result repeat exactly at the
|
|
11
|
+
// edges. The pattern is therefore not pixel-identical to the analytic version —
|
|
12
|
+
// it is the same kind of noise, not the same noise. At the default strength of
|
|
13
|
+
// 0.05 it modulates brightness by ±2.5%, where the repeat is not findable by eye.
|
|
14
|
+
import { GROUND_NOISE_RES, GROUND_NOISE_TILE_WORLD } from "./ground";
|
|
15
|
+
/** Lattice cells across the tile at octave 0. The analytic version sampled fbm
|
|
16
|
+
* at worldXZ × 3, so a tile spanning 8 world units covers 24 base cells — the
|
|
17
|
+
* same spatial frequency the surface had before. */
|
|
18
|
+
const BASE_CELLS = GROUND_NOISE_TILE_WORLD * 3;
|
|
19
|
+
const OCTAVES = 4;
|
|
20
|
+
/** Deterministic lattice hash. Integer coordinates are wrapped by the caller,
|
|
21
|
+
* which is what makes the tile seamless. */
|
|
22
|
+
function hash(ix, iy) {
|
|
23
|
+
let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263);
|
|
24
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
25
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
26
|
+
}
|
|
27
|
+
/** Value noise on a lattice that repeats every `period` cells. */
|
|
28
|
+
function periodicValueNoise(x, y, period) {
|
|
29
|
+
const ix = Math.floor(x);
|
|
30
|
+
const iy = Math.floor(y);
|
|
31
|
+
const fx = x - ix;
|
|
32
|
+
const fy = y - iy;
|
|
33
|
+
// Smoothstep weights — same curve the WGSL used.
|
|
34
|
+
const ux = fx * fx * (3 - 2 * fx);
|
|
35
|
+
const uy = fy * fy * (3 - 2 * fy);
|
|
36
|
+
const x0 = ((ix % period) + period) % period;
|
|
37
|
+
const y0 = ((iy % period) + period) % period;
|
|
38
|
+
const x1 = (x0 + 1) % period;
|
|
39
|
+
const y1 = (y0 + 1) % period;
|
|
40
|
+
const a = hash(x0, y0);
|
|
41
|
+
const b = hash(x1, y0);
|
|
42
|
+
const c = hash(x0, y1);
|
|
43
|
+
const d = hash(x1, y1);
|
|
44
|
+
return (a + (b - a) * ux) * (1 - uy) + (c + (d - c) * ux) * uy;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The tile as 8-bit luminance, plus its mip chain (box-filtered, which is exact
|
|
48
|
+
* for a repeating texture). Mips are the point as much as the speed: procedural
|
|
49
|
+
* noise has no mip chain, so at grazing angles it shimmered.
|
|
50
|
+
*/
|
|
51
|
+
export function buildGroundNoiseMips() {
|
|
52
|
+
const levels = [];
|
|
53
|
+
const res = GROUND_NOISE_RES;
|
|
54
|
+
const base = new Uint8Array(res * res);
|
|
55
|
+
for (let py = 0; py < res; py++) {
|
|
56
|
+
for (let px = 0; px < res; px++) {
|
|
57
|
+
// Texel centre, in units of octave-0 lattice cells.
|
|
58
|
+
const x = ((px + 0.5) / res) * BASE_CELLS;
|
|
59
|
+
const y = ((py + 0.5) / res) * BASE_CELLS;
|
|
60
|
+
let v = 0;
|
|
61
|
+
let amp = 0.5;
|
|
62
|
+
let freq = 1;
|
|
63
|
+
for (let o = 0; o < OCTAVES; o++) {
|
|
64
|
+
v += amp * periodicValueNoise(x * freq, y * freq, BASE_CELLS * freq);
|
|
65
|
+
freq *= 2;
|
|
66
|
+
amp *= 0.5;
|
|
67
|
+
}
|
|
68
|
+
base[py * res + px] = Math.max(0, Math.min(255, Math.round(v * 255)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
levels.push(base);
|
|
72
|
+
let src = base;
|
|
73
|
+
let size = res;
|
|
74
|
+
while (size > 1) {
|
|
75
|
+
const half = size >> 1;
|
|
76
|
+
const dst = new Uint8Array(half * half);
|
|
77
|
+
for (let y = 0; y < half; y++) {
|
|
78
|
+
for (let x = 0; x < half; x++) {
|
|
79
|
+
const s = y * 2 * size + x * 2;
|
|
80
|
+
dst[y * half + x] = (src[s] + src[s + 1] + src[s + size] + src[s + size + 1] + 2) >> 2;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
levels.push(dst);
|
|
84
|
+
src = dst;
|
|
85
|
+
size = half;
|
|
86
|
+
}
|
|
87
|
+
return levels;
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Where the cast/trail history sits in the shared storage buffer. */
|
|
2
|
+
export type CastLayout = {
|
|
3
|
+
subjects: number;
|
|
4
|
+
samples: number;
|
|
5
|
+
base: number;
|
|
6
|
+
trailBase: number;
|
|
7
|
+
slots: number;
|
|
8
|
+
};
|
|
9
|
+
/** How the author's quads combine with the scene. */
|
|
10
|
+
export type ParticleBlend = "alpha" | "additive";
|
|
11
|
+
export type ParticleSource = {
|
|
12
|
+
/** The author's WGSL verbatim. */
|
|
13
|
+
wgsl: string;
|
|
14
|
+
/** Simultaneous particles. Fixed at install; the pool recycles rather than grows. */
|
|
15
|
+
count: number;
|
|
16
|
+
blend: ParticleBlend;
|
|
17
|
+
/** Feed the bloom pyramid. Off by default — rain should not glow. */
|
|
18
|
+
bloom: boolean;
|
|
19
|
+
};
|
|
20
|
+
/** Bytes per particle. Explicitly padded — see the struct below. */
|
|
21
|
+
export declare const PARTICLE_STRIDE = 48;
|
|
22
|
+
/** `// @particles 4096` — how many live at once. */
|
|
23
|
+
export declare function parseParticleCount(wgsl: string, max: number): number;
|
|
24
|
+
/** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
|
|
25
|
+
export declare function parseParticleBloom(wgsl: string): boolean;
|
|
26
|
+
/** `// @blend additive` — default is straight alpha. */
|
|
27
|
+
export declare function parseParticleBlend(wgsl: string): ParticleBlend;
|
|
28
|
+
/** Does the source define the particle contract? All three are required together. */
|
|
29
|
+
export declare function particleEntryPoints(wgsl: string): {
|
|
30
|
+
init: boolean;
|
|
31
|
+
step: boolean;
|
|
32
|
+
shade: boolean;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Spawn, age, recycle.
|
|
36
|
+
*
|
|
37
|
+
* One kernel for both birth and update, because a dead slot and a new particle
|
|
38
|
+
* are the same write — a pool that recycles has no allocation and therefore no
|
|
39
|
+
* spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
|
|
40
|
+
* only by whatever the author randomises in `particleInit`, which for rain and
|
|
41
|
+
* snow is exactly right, and for a burst is what the age offset is for.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildParticleComputeShader(src: ParticleSource, cast: CastLayout): string;
|
|
44
|
+
/**
|
|
45
|
+
* One camera-facing quad per live particle.
|
|
46
|
+
*
|
|
47
|
+
* Six vertices, no index or vertex buffer: the corners are derived from
|
|
48
|
+
* `vertex_index` and the particle is read from storage by `instance_index`, so a
|
|
49
|
+
* draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
|
|
50
|
+
* basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
|
|
51
|
+
* the quad square on screen no matter where the camera rolls.
|
|
52
|
+
*
|
|
53
|
+
* A dead particle collapses to a degenerate quad instead of being culled on the
|
|
54
|
+
* CPU — the alternative is a compacted draw list, which costs a prefix sum and a
|
|
55
|
+
* readback every frame to save vertices the rasteriser was going to reject anyway.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildParticleRenderShader(src: ParticleSource, cast: CastLayout): string;
|
|
58
|
+
//# sourceMappingURL=particles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AAkBA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAmC9G,qDAAqD;AACrD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,UAAU,CAAA;AAEhD,MAAM,MAAM,cAAc,GAAG;IAC3B,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,aAAa,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,eAAe,KAAK,CAAA;AAiIjC,oDAAoD;AACpD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAOpE;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAE9D;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAMlG;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAwCxF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA2GvF"}
|
|
@@ -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
|
+
}
|