@aethervtt/shard-render 0.1.0 → 0.2.1

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.
Files changed (63) hide show
  1. package/dist/ablation.d.ts +74 -0
  2. package/dist/ablation.js +117 -0
  3. package/dist/atmosphere-nodes.js +100 -12
  4. package/dist/atmosphere-shaders.js +17 -5
  5. package/dist/baseline/exposure.d.ts +1 -1
  6. package/dist/baseline/exposure.js +18 -5
  7. package/dist/crowd.d.ts +21 -0
  8. package/dist/crowd.js +121 -0
  9. package/dist/decals-plugin.d.ts +5 -0
  10. package/dist/decals-plugin.js +54 -0
  11. package/dist/decals.d.ts +69 -0
  12. package/dist/decals.js +326 -0
  13. package/dist/deferred.js +4 -1
  14. package/dist/display-nodes.js +24 -11
  15. package/dist/foliage-budget.d.ts +64 -0
  16. package/dist/foliage-budget.js +108 -0
  17. package/dist/foliage-path.d.ts +19 -0
  18. package/dist/foliage-path.js +7 -0
  19. package/dist/foliage-shaders.d.ts +11 -0
  20. package/dist/foliage-shaders.js +504 -0
  21. package/dist/foliage.d.ts +194 -0
  22. package/dist/foliage.js +1027 -0
  23. package/dist/forward.js +26 -2
  24. package/dist/graph.d.ts +34 -0
  25. package/dist/graph.js +139 -14
  26. package/dist/index.d.ts +11 -1
  27. package/dist/index.js +57 -0
  28. package/dist/overlay-registry.d.ts +3 -1
  29. package/dist/overlay-registry.js +3 -1
  30. package/dist/overlays.js +5 -2
  31. package/dist/perf-overlay.d.ts +9 -0
  32. package/dist/perf-overlay.js +125 -0
  33. package/dist/plugin.d.ts +11 -1
  34. package/dist/plugin.js +33 -2
  35. package/dist/post-common.d.ts +20 -2
  36. package/dist/post-common.js +79 -2
  37. package/dist/post-nodes.d.ts +5 -0
  38. package/dist/post-nodes.js +136 -104
  39. package/dist/post-scene.d.ts +130 -0
  40. package/dist/post-scene.js +164 -0
  41. package/dist/post-shaders.js +41 -63
  42. package/dist/post.js +11 -1
  43. package/dist/ribbons-plugin.d.ts +5 -0
  44. package/dist/ribbons-plugin.js +59 -0
  45. package/dist/ribbons.d.ts +44 -0
  46. package/dist/ribbons.js +288 -0
  47. package/dist/scenario.d.ts +70 -0
  48. package/dist/scenario.js +125 -0
  49. package/dist/shaders.js +58 -5
  50. package/dist/shadows.d.ts +2 -1
  51. package/dist/shadows.js +3 -1
  52. package/dist/standard.js +6 -0
  53. package/dist/testing.d.ts +1 -0
  54. package/dist/testing.js +6 -0
  55. package/dist/timer.d.ts +16 -1
  56. package/dist/timer.js +66 -19
  57. package/dist/vfx-draw.d.ts +37 -0
  58. package/dist/vfx-draw.js +291 -0
  59. package/dist/vfx-state.d.ts +42 -0
  60. package/dist/vfx-state.js +81 -0
  61. package/dist/vfx.d.ts +69 -0
  62. package/dist/vfx.js +103 -0
  63. package/package.json +19 -11
@@ -0,0 +1,288 @@
1
+ import { defineResource, defineSystem } from "@aethervtt/shard-core";
2
+ import { FrameDemand, Time } from "@aethervtt/shard-runtime";
3
+ import { GlobalTransform } from "@aethervtt/shard-transform";
4
+ import { Beam, Trail } from "./vfx.js";
5
+ import {
6
+ createRecord,
7
+ matrixChanged,
8
+ number,
9
+ point,
10
+ recordDescription,
11
+ refreshRecord,
12
+ VFX_STRIDE
13
+ } from "./vfx-state.js";
14
+ const Ribbons = defineResource("render/Ribbons", {
15
+ init: () => ({
16
+ records: [],
17
+ trails: /* @__PURE__ */ new Map(),
18
+ beams: /* @__PURE__ */ new Map(),
19
+ frame: 0,
20
+ rejected: 0
21
+ })
22
+ });
23
+ function create(world, entity, beam2) {
24
+ const capacity = beam2 ? 256 : world.get(entity, Trail).capacity;
25
+ return {
26
+ ...createRecord(
27
+ world,
28
+ entity,
29
+ beam2 ? Beam : Trail,
30
+ beam2 ? "beam" : "trail",
31
+ (capacity - 1) * 6
32
+ ),
33
+ points: new Float64Array(capacity * 5),
34
+ capacity,
35
+ head: 0,
36
+ pointsCount: 0,
37
+ distance: 0,
38
+ targetMatrix: new Float64Array(12),
39
+ scratch: new Float64Array(6)
40
+ };
41
+ }
42
+ function noise(seed, index) {
43
+ let x = (seed ^ Math.imul(index + 1, 2654435769)) >>> 0;
44
+ x = Math.imul(x ^ x >>> 16, 569420461);
45
+ x = Math.imul(x ^ x >>> 15, 1935289751);
46
+ return ((x ^ x >>> 15) >>> 0) / 4294967295 * 2 - 1;
47
+ }
48
+ function trail(r, playing, paused) {
49
+ const life = number(r, "lifetime"), p = r.points;
50
+ while (r.pointsCount && r.age - p[r.head * 5 + 3] >= life) {
51
+ r.head = (r.head + 1) % r.capacity;
52
+ r.pointsCount--;
53
+ }
54
+ if (!playing || paused) return;
55
+ const offset = r.columns.offset, o = r.row * 3;
56
+ point(r.scratch, 0, r.matrix, offset[o], offset[o + 1], offset[o + 2]);
57
+ let distance = 0;
58
+ if (r.pointsCount) {
59
+ const last = (r.head + r.pointsCount - 1) % r.capacity * 5;
60
+ const dx = r.scratch[0] - p[last], dy = r.scratch[1] - p[last + 1], dz = r.scratch[2] - p[last + 2];
61
+ distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
62
+ if (distance < number(r, "minDistance")) return;
63
+ if (distance > number(r, "breakDistance")) {
64
+ r.head = 0;
65
+ r.pointsCount = 0;
66
+ r.distance = 0;
67
+ distance = 0;
68
+ }
69
+ }
70
+ if (r.pointsCount === r.capacity) {
71
+ r.head = (r.head + 1) % r.capacity;
72
+ r.pointsCount--;
73
+ }
74
+ const at = (r.head + r.pointsCount++) % r.capacity * 5;
75
+ p[at] = r.scratch[0];
76
+ p[at + 1] = r.scratch[1];
77
+ p[at + 2] = r.scratch[2];
78
+ p[at + 3] = r.age;
79
+ p[at + 4] = r.distance += distance;
80
+ }
81
+ function beam(world, r) {
82
+ const authored = r.columns.points[r.row];
83
+ if (authored.length > 256 || authored.length === 1) {
84
+ r.error = "render/beam-points";
85
+ r.pointsCount = 0;
86
+ return;
87
+ }
88
+ const p = r.points;
89
+ r.head = 0;
90
+ if (authored.length) {
91
+ r.pointsCount = authored.length;
92
+ for (let i = 0; i < authored.length; i++) {
93
+ const v = authored[i];
94
+ point(p, i * 5, r.matrix, v[0], v[1], v[2]);
95
+ }
96
+ } else {
97
+ const target = number(r, "target"), start = r.columns.start, end = r.columns.end, o = r.row * 3;
98
+ if (target >= 0 && (!world.isAlive(target) || !world.has(target, GlobalTransform))) {
99
+ r.error = "render/beam-target";
100
+ r.pointsCount = 0;
101
+ return;
102
+ }
103
+ const m = target >= 0 ? r.targetMatrix : r.matrix;
104
+ if (target >= 0) matrixChanged(world, target, m);
105
+ point(r.scratch, 0, r.matrix, start[o], start[o + 1], start[o + 2]);
106
+ point(r.scratch, 3, m, end[o], end[o + 1], end[o + 2]);
107
+ const segments = number(r, "segments"), strength = number(r, "noise"), seed = number(r, "seed");
108
+ r.pointsCount = segments + 1;
109
+ for (let i = 0; i <= segments; i++) {
110
+ const f = i / segments, amplitude = i === 0 || i === segments ? 0 : strength;
111
+ for (let k = 0; k < 3; k++)
112
+ p[i * 5 + k] = r.scratch[k] + (r.scratch[k + 3] - r.scratch[k]) * f + noise(seed, i * 3 + k) * amplitude;
113
+ }
114
+ }
115
+ let distance = 0;
116
+ for (let i = 0; i < r.pointsCount; i++) {
117
+ if (i) {
118
+ const dx = p[i * 5] - p[(i - 1) * 5], dy = p[i * 5 + 1] - p[(i - 1) * 5 + 1], dz = p[i * 5 + 2] - p[(i - 1) * 5 + 2];
119
+ distance += Math.sqrt(dx * dx + dy * dy + dz * dz);
120
+ }
121
+ p[i * 5 + 4] = distance;
122
+ }
123
+ r.distance = distance;
124
+ }
125
+ function ribbonGeometry(r) {
126
+ r.count = 0;
127
+ const curve = r.columns.widthCurve[r.row];
128
+ if (curve.length > 16) {
129
+ r.error = "render/ribbon-curve";
130
+ return;
131
+ }
132
+ for (let i = 0; i < curve.length; i++)
133
+ if (!Number.isFinite(curve[i].at) || !Number.isFinite(curve[i].value) || curve[i].at < 0 || curve[i].at > 1 || curve[i].value < 0 || curve[i].value > 16 || i > 0 && curve[i].at <= curve[i - 1].at) {
134
+ r.error = "render/ribbon-curve";
135
+ return;
136
+ }
137
+ const p = r.points, out = r.vertices, c = r.columns.color, e = r.columns.endColor;
138
+ const width = number(r, "width"), repeat = number(r, "repeat"), scroll = number(r, "scroll") * r.age;
139
+ const life = r.kind === "trail" ? number(r, "lifetime") : 1;
140
+ for (let segment = 0; segment < r.pointsCount - 1; segment++) {
141
+ const a = (r.head + segment) % r.capacity * 5, b = (r.head + segment + 1) % r.capacity * 5;
142
+ const dx = p[b] - p[a], dy = p[b + 1] - p[a + 1], dz = p[b + 2] - p[a + 2];
143
+ if (dx * dx + dy * dy + dz * dz < 1e-16) continue;
144
+ for (let v = 0; v < 6; v++) {
145
+ const next = v === 1 || v === 2 || v === 4, side = v === 2 || v === 4 || v === 5 ? 1 : -1;
146
+ const index = segment + (next ? 1 : 0), at = next ? b : a;
147
+ const prev = (r.head + Math.max(0, index - 1)) % r.capacity * 5, after = (r.head + Math.min(r.pointsCount - 1, index + 1)) % r.capacity * 5;
148
+ const f = Math.min(
149
+ 1,
150
+ Math.max(
151
+ 0,
152
+ r.kind === "trail" ? (r.age - p[at + 3]) / life : p[at + 4] / Math.max(r.distance, 1e-9)
153
+ )
154
+ );
155
+ let scale = 1;
156
+ if (curve.length) {
157
+ scale = curve[0].value;
158
+ for (let k = 1; k < curve.length; k++) {
159
+ const x = curve[k - 1], y = curve[k];
160
+ if (f >= y.at) scale = y.value;
161
+ else if (f > x.at) {
162
+ scale = x.value + (y.value - x.value) * (f - x.at) / (y.at - x.at);
163
+ break;
164
+ } else break;
165
+ }
166
+ }
167
+ const o = r.count++ * VFX_STRIDE;
168
+ out[o] = p[at];
169
+ out[o + 1] = p[at + 1];
170
+ out[o + 2] = p[at + 2];
171
+ out[o + 3] = p[after] - p[prev];
172
+ out[o + 4] = p[after + 1] - p[prev + 1];
173
+ out[o + 5] = p[after + 2] - p[prev + 2];
174
+ out[o + 6] = p[at + 4] / repeat + scroll;
175
+ out[o + 7] = (side + 1) * 0.5;
176
+ for (let k = 0; k < 4; k++)
177
+ out[o + 8 + k] = c[r.row * 4 + k] + (e[r.row * 4 + k] - c[r.row * 4 + k]) * f;
178
+ out[o + 12] = width * scale * 0.5;
179
+ out[o + 13] = side;
180
+ }
181
+ }
182
+ r.version++;
183
+ }
184
+ const updateRibbons = defineSystem({
185
+ name: "render/ribbons-update",
186
+ setup: (world) => ({
187
+ trails: world.query({ with: [Trail, GlobalTransform] }),
188
+ beams: world.query({ with: [Beam, GlobalTransform] })
189
+ }),
190
+ run({ trails, beams }, world) {
191
+ const state = world.resource(Ribbons), delta = world.resource(Time).delta;
192
+ state.frame++;
193
+ state.rejected = 0;
194
+ let demand = false;
195
+ for (let kind = 0; kind < 2; kind++) {
196
+ const q = kind ? beams : trails;
197
+ for (let t = 0; t < q.tables.length; t++) {
198
+ const table = q.tables[t];
199
+ for (let row = 0; row < table.count; row++) {
200
+ const entity = table.entities[row];
201
+ const limit = kind ? table.column(Beam, "segments")[row] : table.column(Trail, "capacity")[row];
202
+ if (limit < (kind ? 1 : 2) || limit > (kind ? 255 : 256)) {
203
+ state.rejected++;
204
+ continue;
205
+ }
206
+ const lookup = kind ? state.beams : state.trails;
207
+ let r = lookup.get(entity);
208
+ if (!r) {
209
+ if (state.records.length >= 256) {
210
+ state.rejected++;
211
+ continue;
212
+ }
213
+ r = create(world, entity, kind === 1);
214
+ state.records.push(r);
215
+ lookup.set(entity, r);
216
+ }
217
+ refreshRecord(world, r);
218
+ matrixChanged(world, entity, r.matrix);
219
+ r.seen = state.frame;
220
+ r.error = null;
221
+ const playing = !!number(r, "playing"), paused = !!number(r, "paused");
222
+ if (r.kind === "trail" && r.capacity !== number(r, "capacity")) {
223
+ r.capacity = number(r, "capacity");
224
+ r.points = new Float64Array(r.capacity * 5);
225
+ r.vertices = new Float32Array((r.capacity - 1) * 6 * VFX_STRIDE);
226
+ r.head = 0;
227
+ r.pointsCount = 0;
228
+ }
229
+ if (playing && !r.wasPlaying && r.kind === "beam") r.age = 0;
230
+ r.wasPlaying = playing;
231
+ if (!paused) r.age += delta;
232
+ if (kind) {
233
+ if (playing && (!paused || r.pointsCount === 0)) beam(world, r);
234
+ else if (!playing) r.pointsCount = 0;
235
+ else if (r.columns.points[r.row].length === 0) {
236
+ const target = number(r, "target");
237
+ if (target >= 0 && (!world.isAlive(target) || !world.has(target, GlobalTransform))) {
238
+ r.error = "render/beam-target";
239
+ r.pointsCount = 0;
240
+ }
241
+ }
242
+ } else trail(r, playing, paused);
243
+ ribbonGeometry(r);
244
+ if (!paused && (playing || r.pointsCount > 0)) demand = true;
245
+ }
246
+ }
247
+ }
248
+ for (let i = state.records.length - 1; i >= 0; i--)
249
+ if (state.records[i].seen !== state.frame) {
250
+ const r = state.records[i];
251
+ (r.kind === "beam" ? state.beams : state.trails).delete(r.entity);
252
+ state.records.splice(i, 1);
253
+ }
254
+ world.tryResource(FrameDemand)?.set("render/ribbons", demand);
255
+ }
256
+ });
257
+ function shiftRibbons(world, x, y, z) {
258
+ const records = world.resource(Ribbons).records;
259
+ for (let i = 0; i < records.length; i++) {
260
+ const r = records[i];
261
+ for (let j = 0; j < r.pointsCount; j++) {
262
+ const at = (r.head + j) % r.capacity * 5;
263
+ r.points[at] = r.points[at] + x;
264
+ r.points[at + 1] = r.points[at + 1] + y;
265
+ r.points[at + 2] = r.points[at + 2] + z;
266
+ }
267
+ }
268
+ }
269
+ function describeRibbons(world) {
270
+ const s = world.resource(Ribbons);
271
+ return {
272
+ rejected: s.rejected,
273
+ error: s.rejected ? "render/ribbon-limit" : null,
274
+ records: s.records.map((r) => ({
275
+ ...recordDescription(r),
276
+ cpuBytes: r.vertices.byteLength + r.points.byteLength + r.matrix.byteLength + r.targetMatrix.byteLength + r.scratch.byteLength,
277
+ points: r.pointsCount,
278
+ capacity: r.capacity
279
+ }))
280
+ };
281
+ }
282
+ export {
283
+ Ribbons,
284
+ describeRibbons,
285
+ ribbonGeometry,
286
+ shiftRibbons,
287
+ updateRibbons
288
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Scenario tests (0075), a test helper exported from `@aethervtt/shard-render/testing`: a fixture's
3
+ * camera path runs through a 0074 capture, and each slice of the scenario (`bench/perf/budgets.json`)
4
+ * is read from it with the covers rule (`Capture.spanTime`), so tests, `shard profile` and the
5
+ * `perf` overlay measure the same thing. On a machine whose GPU passes overlap (machines.json's
6
+ * `passTiming: "ablation"`), GPU slices are measured by ablation under `pnpm bench` instead.
7
+ */
8
+ import { type World } from '@aethervtt/shard-core';
9
+ import type { Capture, CaptureSummary } from '@aethervtt/shard-core/capture';
10
+ import { type Track } from '@aethervtt/shard-core/test-env';
11
+ import type { expect as vitestExpect } from 'vitest';
12
+ export interface ScenarioSlice {
13
+ key: string;
14
+ track: Track;
15
+ /** Spans that ran in the capture and that the key covers. */
16
+ covers: string[];
17
+ /** Time the key covered in each captured frame (ms). */
18
+ perFrame: Float64Array;
19
+ /** The p95 of it: frames whose GPU timings landed, for a GPU slice. Ablation's cost instead. */
20
+ p95: number;
21
+ measuredBy: 'capture' | 'ablation';
22
+ /**
23
+ * Ablation only: half the range of the cost over rounds, in ms. A pass's cost is the difference
24
+ * of two frame times that vary by milliseconds, so a sub-millisecond pass can't be resolved
25
+ * below this; the budget check allows it.
26
+ */
27
+ noise?: number;
28
+ }
29
+ export interface ScenarioRun {
30
+ name: string;
31
+ summary: CaptureSummary;
32
+ capture: Capture;
33
+ /** Every span that ran during the capture, by name. */
34
+ spans: string[];
35
+ /** Each frame's CPU time (`frame`) and GPU time (`gpu:frame`; 0 where none landed). */
36
+ frame: {
37
+ cpu: Float64Array;
38
+ gpu: Float64Array;
39
+ cpuP95: number;
40
+ gpuP95: number;
41
+ };
42
+ /** Whether GPU passes were timed (the device has `timestamp-query`). */
43
+ gpuTimed: boolean;
44
+ slices: ScenarioSlice[];
45
+ }
46
+ export interface ScenarioOptions {
47
+ /** Frames along the camera path, each run through the capture. */
48
+ frames: number;
49
+ /** Puts the camera at path frame `i`, runs one app frame, and waits for the GPU. */
50
+ step: (i: number) => void | Promise<void>;
51
+ /** Ablation under `pnpm bench` on a machine that needs it: frames per measurement, rounds. */
52
+ ablation?: {
53
+ frames?: number;
54
+ rounds?: number; /** Ablate whatever the machine (tests of this helper). */
55
+ force?: boolean;
56
+ };
57
+ }
58
+ /**
59
+ * Runs a scenario's camera path through a capture and measures its frame and slices (0075). The
60
+ * slices are the scenario's keys in budgets.json, `headroom` aside.
61
+ */
62
+ export declare function measureScenario(world: World, name: string, options: ScenarioOptions): Promise<ScenarioRun>;
63
+ /**
64
+ * The checks of a scenario test. In `pnpm test`: the capture ran every frame, and every slice key
65
+ * covers at least one span that ran (GPU ones where the device times passes). Under `pnpm bench`:
66
+ * p95 of the frame and of each slice against the scenario's budgets, soft, so every one is
67
+ * recorded for `bench/perf/report.json`. A slice measured by ablation fails only when it's over by
68
+ * more than its noise: what's checked (and reported) is its cost minus the noise.
69
+ */
70
+ export declare function expectScenario(run: ScenarioRun, expect: typeof vitestExpect): void;
@@ -0,0 +1,125 @@
1
+ import { spanCovers, spanName } from "@aethervtt/shard-core";
2
+ import {
3
+ budget,
4
+ passTiming,
5
+ scenarioSlices,
6
+ timingMode
7
+ } from "@aethervtt/shard-core/test-env";
8
+ import { capturePerf } from "@aethervtt/shard-runtime";
9
+ import { ablatePasses } from "./ablation.js";
10
+ import { Graph } from "./plugin.js";
11
+ function p95Of(values) {
12
+ const sorted = Array.from(values).sort((a, b) => a - b);
13
+ if (sorted.length === 0) return 0;
14
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
15
+ }
16
+ async function measureScenario(world, name, options) {
17
+ const keys = scenarioSlices(name);
18
+ let i = 0;
19
+ const { summary, capture } = await capturePerf(world, {
20
+ frames: options.frames,
21
+ write: false,
22
+ trace: false,
23
+ keep: true,
24
+ step: async () => {
25
+ await options.step(Math.min(i, options.frames - 1));
26
+ i++;
27
+ }
28
+ });
29
+ if (!capture) throw new Error("capturePerf kept no capture");
30
+ const seen = /* @__PURE__ */ new Set();
31
+ for (let e = 0; e < capture.eventCount; e++) seen.add(capture.events.ids[e]);
32
+ const spans = [...seen].map(spanName).sort();
33
+ const gpuFrame = capture.spanTime("gpu:frame");
34
+ const timedFrames = [];
35
+ for (let f = 0; f < gpuFrame.length; f++) if (gpuFrame[f] > 0) timedFrames.push(f);
36
+ const gpuTimed = timedFrames.length > 0;
37
+ const slices = [];
38
+ for (const track of ["gpu", "cpu"]) {
39
+ for (const key of keys[track]) {
40
+ const perFrame = capture.spanTime(key);
41
+ const values = track === "gpu" ? timedFrames.map((f) => perFrame[f]) : perFrame;
42
+ slices.push({
43
+ key,
44
+ track,
45
+ covers: spans.filter((s) => spanCovers(key, s)),
46
+ perFrame,
47
+ p95: p95Of(values),
48
+ measuredBy: "capture"
49
+ });
50
+ }
51
+ }
52
+ if ((options.ablation?.force || passTiming() === "ablation") && gpuTimed) {
53
+ const graph = world.resource(Graph);
54
+ const nodes = graph.nodeNames();
55
+ const groups = {};
56
+ for (const s of slices) {
57
+ if (s.track !== "gpu") continue;
58
+ const covered = nodes.filter((n) => spanCovers(s.key, `gpu:${n}`));
59
+ if (covered.length > 0) groups[s.key] = covered;
60
+ }
61
+ const passes = Object.keys(groups);
62
+ if (passes.length > 0) {
63
+ const result = await ablatePasses(
64
+ world,
65
+ {
66
+ passes,
67
+ groups,
68
+ frames: options.ablation?.frames ?? 60,
69
+ rounds: options.ablation?.rounds ?? 3
70
+ },
71
+ () => options.step(options.frames - 1)
72
+ );
73
+ for (const cost of result.passes) {
74
+ const s = slices.find((x) => x.track === "gpu" && x.key === cost.pass);
75
+ s.p95 = Math.max(0, cost.ms);
76
+ s.measuredBy = "ablation";
77
+ s.noise = (Math.max(...cost.rounds) - Math.min(...cost.rounds)) / 2;
78
+ }
79
+ }
80
+ }
81
+ return {
82
+ name,
83
+ summary,
84
+ capture,
85
+ spans,
86
+ frame: {
87
+ cpu: capture.frames.cpu,
88
+ gpu: gpuFrame,
89
+ cpuP95: p95Of(capture.frames.cpu),
90
+ gpuP95: p95Of(timedFrames.map((f) => gpuFrame[f]))
91
+ },
92
+ gpuTimed,
93
+ slices
94
+ };
95
+ }
96
+ function expectScenario(run, expect) {
97
+ expect(run.summary.frames.count).toBe(run.capture.frameCount);
98
+ expect(run.capture.frameCount).toBeGreaterThan(0);
99
+ for (const s of run.slices) {
100
+ if (s.track === "gpu" && !run.gpuTimed) continue;
101
+ expect(s.covers, `${run.name}: slice "${s.key}" covers no span that ran`).not.toEqual([]);
102
+ }
103
+ if (timingMode !== "bench") return;
104
+ const soft = expect.soft;
105
+ soft(run.frame.cpuP95, `${run.name}: CPU frame p95`).toBeLessThan(
106
+ budget(`${run.name}`, { track: "cpu" })
107
+ );
108
+ if (run.gpuTimed) {
109
+ soft(run.frame.gpuP95, `${run.name}: GPU frame p95`).toBeLessThan(
110
+ budget(`${run.name}`, { track: "gpu" })
111
+ );
112
+ }
113
+ for (const s of run.slices) {
114
+ if (s.track === "gpu" && !run.gpuTimed) continue;
115
+ const measured = s.noise === void 0 ? s.p95 : Math.max(0, s.p95 - s.noise);
116
+ const how = s.noise === void 0 ? s.measuredBy : `ablation, ${s.p95.toFixed(3)} \xB1 ${s.noise.toFixed(3)} ms`;
117
+ soft(measured, `${run.name}: ${s.track} slice ${s.key} p95 (${how})`).toBeLessThan(
118
+ budget(`${run.name}`, { slice: s.key, track: s.track })
119
+ );
120
+ }
121
+ }
122
+ export {
123
+ expectScenario,
124
+ measureScenario
125
+ };
package/dist/shaders.js CHANGED
@@ -664,6 +664,26 @@ fn heat(count: u32) -> vec3f {
664
664
  * Lighting stage: the only place lights are evaluated. Returns scene-referred radiance.
665
665
  * Directional lights (with cascaded shadows), then clustered point and spot lights, then ambient.
666
666
  */
667
+ /**
668
+ * SSAO at a full-resolution pixel, from the half-resolution AO and view depth (rg): the four
669
+ * nearest texels, weighted by how well their depth matches this pixel's. Without SSAO a white
670
+ * 1\xD71 texture is bound, which gives 1.
671
+ */
672
+ fn ssao_at(frag: vec2f, z: f32) -> f32 {
673
+ let half_size = vec2i(textureDimensions(ao_texture));
674
+ let base = vec2i(floor((frag - 1.0) * 0.5));
675
+ var sum = 0.0;
676
+ var weight = 0.0;
677
+ for (var k = 0; k < 4; k++) {
678
+ let q = clamp(base + vec2i(k & 1, k >> 1), vec2i(0), half_size - 1);
679
+ let s = textureLoad(ao_texture, q, 0);
680
+ let w = 1.0 / (1e-3 + abs(s.y - z) / max(z, 1e-3));
681
+ sum += s.x * w;
682
+ weight += w;
683
+ }
684
+ return sum / max(weight, 1e-6);
685
+ }
686
+
667
687
  fn apply_lighting(p: PbrInput, world_position: vec3f, frag_coord: vec4f, flags: u32) -> vec3f {
668
688
  let n = p.normal;
669
689
  let v = normalize(view.cameraPosition - world_position);
@@ -754,10 +774,7 @@ fn apply_lighting(p: PbrInput, world_position: vec3f, frag_coord: vec4f, flags:
754
774
  // Ambient: image-based lighting from the environment, or the uniform AmbientLight (cd/m\xB2).
755
775
  // SSAO darkens it on opaque surfaces (it measures the depth buffer, which blended ones aren't in).
756
776
  var surface = p;
757
- if (p.alpha >= 0.999) {
758
- let dims = vec2i(textureDimensions(ao_texture));
759
- surface.occlusion *= textureLoad(ao_texture, min(vec2i(frag_coord.xy), dims - 1), 0).r;
760
- }
777
+ if (p.alpha >= 0.999) { surface.occlusion *= ssao_at(frag_coord.xy, view_depth); }
761
778
  if (view.envParams.w > 0.5) {
762
779
  @if(!SKY_VISIBILITY) color += environment_light(surface, n, v);
763
780
  @if(SKY_VISIBILITY) color += interior_ambient(environment_light(surface, n, v), diffuse_color * surface.occlusion, world_position, n);
@@ -1083,10 +1100,21 @@ fn environment_light(p: PbrInput, n: vec3f, v: vec3f) -> vec3f {
1083
1100
  /** The environment's own radiance toward d (the background), in cd/m\xB2. */
1084
1101
  fn environment_background(d: vec3f) -> vec3f {
1085
1102
  return textureSampleLevel(env_source, env_sampler, env_rotate(d), 0.0).rgb * view.envParams.x;
1103
+ }`,
1104
+ "shard::post::glow": `
1105
+ /**
1106
+ * Bloom's first level over the image: mixed, conserving energy, without a threshold; added with
1107
+ * one. \`glow\`: intensity, the chain's level count, mix (1) or add (0), 0. The tonemap applies it
1108
+ * as it reads the image, and auto exposure meters the same mix.
1109
+ */
1110
+ fn add_glow(c: vec3f, level0: vec3f, glow: vec4f) -> vec3f {
1111
+ let g = level0 / glow.y;
1112
+ return select(c + g * glow.x, mix(c, g, glow.x), glow.z > 0.5);
1086
1113
  }`,
1087
1114
  "shard::post::tonemap": `
1088
1115
  import shard::color::{ linear_to_srgb, srgb_to_linear, ign, luminance };
1089
1116
  import shard::tonemap::tonemap;
1117
+ @if(BLOOM) import shard::post::glow::add_glow;
1090
1118
 
1091
1119
  struct TonemapParams {
1092
1120
  /** curve, dither, flags (1 grading, 2 vignette, 4 LUT, 8 sRGB LUT), 0. */
@@ -1102,12 +1130,16 @@ struct TonemapParams {
1102
1130
  vignette: vec4f,
1103
1131
  /** 1 / width, 1 / height of the view. */
1104
1132
  texel: vec4f,
1133
+ /** Bloom: intensity, level count, mix (1) or add (0), 0. */
1134
+ glow: vec4f,
1105
1135
  }
1106
1136
 
1107
1137
  @group(0) @binding(0) var hdr: texture_2d<f32>;
1108
1138
  @group(0) @binding(1) var<uniform> params: TonemapParams;
1109
1139
  @group(0) @binding(2) var lut: texture_2d<f32>;
1110
1140
  @group(0) @binding(3) var lut_sampler: sampler;
1141
+ /** Bloom's first level, composited here instead of in a pass of its own. */
1142
+ @if(BLOOM) @group(0) @binding(4) var bloom: texture_2d<f32>;
1111
1143
 
1112
1144
  const LIN_TO_LMS = mat3x3f(
1113
1145
  vec3f(3.90405e-1, 7.08416e-2, 2.31082e-2),
@@ -1145,7 +1177,11 @@ fn apply_lut(c: vec3f) -> vec3f {
1145
1177
  }
1146
1178
 
1147
1179
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1148
- let texel = textureLoad(hdr, vec2i(p.xy), 0);
1180
+ var texel = textureLoad(hdr, vec2i(p.xy), 0);
1181
+ @if(BLOOM) {
1182
+ let level0 = textureSampleLevel(bloom, lut_sampler, p.xy * params.texel.xy, 0.0).rgb;
1183
+ texel = vec4f(add_glow(texel.rgb, level0, params.glow), texel.a);
1184
+ }
1149
1185
  var hdr_color = max(texel.rgb, vec3f(0.0));
1150
1186
  @if(TRANSPARENT) var alpha = 1.0;
1151
1187
  @if(TRANSPARENT) {
@@ -1234,6 +1270,23 @@ fn world_at(uv: vec2f, depth: f32) -> vec3f {
1234
1270
  return w.xyz / w.w;
1235
1271
  }
1236
1272
 
1273
+ /**
1274
+ * Rounds to nearest on a device that truncates when it stores rg11b10ufloat (Metal does): half a
1275
+ * step of each channel's own exponent (6 mantissa bits in red and green, 5 in blue) added before
1276
+ * the store. Without it every pass through the format loses half a step on average, blue twice
1277
+ * as much, and the image drifts warm.
1278
+ */
1279
+ fn rg11_round(c: vec3f) -> vec3f {
1280
+ let e = exp2(floor(log2(max(c, vec3f(6.2e-5)))));
1281
+ return c + e * vec3f(1.0 / 128.0, 1.0 / 128.0, 1.0 / 64.0);
1282
+ }
1283
+
1284
+ /** A post pass's output into the chain: rounded where it's rg11b10ufloat on a truncating device. */
1285
+ fn post_out(c: vec4f) -> vec4f {
1286
+ @if(RG11_ROUND) return vec4f(rg11_round(c.rgb), c.a);
1287
+ @if(!RG11_ROUND) return c;
1288
+ }
1289
+
1237
1290
  /** Distance along the view axis, in meters. */
1238
1291
  fn view_depth(world: vec3f) -> f32 {
1239
1292
  return -(view.view * vec4f(world, 1.0)).z;
package/dist/shadows.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type World } from '@aethervtt/shard-core';
2
2
  import { GpuBuffer, type GpuContext } from '@aethervtt/shard-gpu';
3
+ import type { FoliageSupport } from './foliage-path.js';
3
4
  import type { NodeContext } from './graph.js';
4
5
  import { type DrawList, type InstanceStore } from './instances.js';
5
6
  import { type LightRecord, type LightStore } from './lights.js';
@@ -134,7 +135,7 @@ export declare function shadowViewLayout(gpu: GpuContext): GPUBindGroupLayout;
134
135
  * `vertex_position` displacement moves shadows too). Opaque materials are vertex-only; masked
135
136
  * standard materials run the surface stage to discard.
136
137
  */
137
- export declare function drawShadowCasters(ctx: NodeContext, target: GPUTextureView, view: ShadowViewDraw, offset: number, uniforms: ShadowPassUniforms, pipelines: MaterialPipelines, shadowView: GPUBindGroupLayout, label: string): void;
138
+ export declare function drawShadowCasters(ctx: NodeContext, target: GPUTextureView, view: ShadowViewDraw, offset: number, uniforms: ShadowPassUniforms, pipelines: MaterialPipelines, shadowView: GPUBindGroupLayout, label: string, foliage?: FoliageSupport, cam?: CameraData, cascade?: number): void;
138
139
  /** Culls instances that cast shadows into a shadow view, and unions their world bounds. */
139
140
  export declare function cullCasters(world: World, view: ShadowViewDraw, planes: Float32Array, box?: Float32Array): boolean;
140
141
  export declare const ShadowsResource: import("@aethervtt/shard-core").ResourceDef<{
package/dist/shadows.js CHANGED
@@ -497,7 +497,7 @@ const SHADOW_BUFFERS = [
497
497
  { arrayStride: 8, attributes: [{ shaderLocation: 3, offset: 0, format: "float32x2" }] },
498
498
  { arrayStride: 16, attributes: [{ shaderLocation: 4, offset: 0, format: "float32x4" }] }
499
499
  ];
500
- function drawShadowCasters(ctx, target, view, offset, uniforms, pipelines, shadowView2, label) {
500
+ function drawShadowCasters(ctx, target, view, offset, uniforms, pipelines, shadowView2, label, foliage, cam, cascade = 0) {
501
501
  const world = ctx.world;
502
502
  const gpu = ctx.gpu;
503
503
  const store = world.resource(Instances);
@@ -516,6 +516,7 @@ function drawShadowCasters(ctx, target, view, offset, uniforms, pipelines, shado
516
516
  const draws0 = view.draws;
517
517
  const instances = draws0.cullView >= 0 ? store.gpuBindGroup : store.bindGroup;
518
518
  if (!instances || !uniforms.bindGroup) {
519
+ if (foliage && cam && uniforms.bindGroup) foliage.drawShadow(ctx, cam, pass, offset, cascade);
519
520
  pass.end();
520
521
  return;
521
522
  }
@@ -607,6 +608,7 @@ function drawShadowCasters(ctx, target, view, offset, uniforms, pipelines, shado
607
608
  pass.draw(gm.count, item.count, 0, item.first);
608
609
  }
609
610
  }
611
+ if (foliage && cam) foliage.drawShadow(ctx, cam, pass, offset, cascade);
610
612
  pass.end();
611
613
  }
612
614
  const PASS_SHADOW = 2;
package/dist/standard.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { definePlugin } from "@aethervtt/shard-runtime";
2
2
  import { atmospherePlugin } from "./atmosphere-plugin.js";
3
3
  import { cutawayPlugin } from "./cutaway-plugin.js";
4
+ import { decalsPlugin } from "./decals-plugin.js";
4
5
  import { deferredPlugin } from "./deferred-plugin.js";
5
6
  import { renderDescribePlugin } from "./describe-plugin.js";
6
7
  import { dynamicResolutionPlugin } from "./dynamic-resolution.js";
7
8
  import { environmentPlugin } from "./environment-plugin.js";
9
+ import { foliagePlugin } from "./foliage.js";
8
10
  import { forwardCorePlugin } from "./forward.js";
9
11
  import { fxaaPlugin } from "./fxaa.js";
10
12
  import { gizmosPlugin } from "./gizmos-plugin.js";
@@ -14,6 +16,7 @@ import { outlinePlugin } from "./outline-plugin.js";
14
16
  import { pickingPlugin } from "./picking-plugin.js";
15
17
  import { pixelPerfectPlugin } from "./pixel-perfect-plugin.js";
16
18
  import { postPlugin } from "./post-plugin.js";
19
+ import { ribbonsPlugin } from "./ribbons-plugin.js";
17
20
  import { shadowCatcherPlugin } from "./shadow-catcher.js";
18
21
  import { skinningPlugin } from "./skinning-plugin.js";
19
22
  import { viewVisibilityPlugin } from "./view-visibility-plugin.js";
@@ -38,7 +41,10 @@ function forwardPlugin(options = {}) {
38
41
  viewVisibilityPlugin,
39
42
  interiorPlugin,
40
43
  shadowCatcherPlugin,
44
+ foliagePlugin,
41
45
  dynamicResolutionPlugin,
46
+ ribbonsPlugin,
47
+ decalsPlugin,
42
48
  renderDescribePlugin
43
49
  );
44
50
  }
package/dist/testing.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { App } from '@aethervtt/shard-runtime';
2
2
  import type { CapturedImage } from './graph.js';
3
+ export { expectScenario, measureScenario, type ScenarioOptions, type ScenarioRun, type ScenarioSlice, } from './scenario.js';
3
4
  /** Renders frames until shaders and pipelines are compiled and nothing is skipped. */
4
5
  export declare function settle(app: App, frames?: number): Promise<void>;
5
6
  /** Settles, then captures a view. */