@aethervtt/shard-render 0.1.0 → 0.2.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/dist/ablation.d.ts +74 -0
- package/dist/ablation.js +117 -0
- package/dist/atmosphere-nodes.js +100 -12
- package/dist/atmosphere-shaders.js +17 -5
- package/dist/baseline/exposure.d.ts +1 -1
- package/dist/baseline/exposure.js +18 -5
- package/dist/crowd.d.ts +21 -0
- package/dist/crowd.js +121 -0
- package/dist/decals-plugin.d.ts +5 -0
- package/dist/decals-plugin.js +54 -0
- package/dist/decals.d.ts +69 -0
- package/dist/decals.js +326 -0
- package/dist/deferred.js +4 -1
- package/dist/display-nodes.js +24 -11
- package/dist/foliage-budget.d.ts +64 -0
- package/dist/foliage-budget.js +108 -0
- package/dist/foliage-path.d.ts +19 -0
- package/dist/foliage-path.js +7 -0
- package/dist/foliage-shaders.d.ts +11 -0
- package/dist/foliage-shaders.js +504 -0
- package/dist/foliage.d.ts +194 -0
- package/dist/foliage.js +1027 -0
- package/dist/forward.js +26 -2
- package/dist/graph.d.ts +34 -0
- package/dist/graph.js +139 -14
- package/dist/index.d.ts +11 -1
- package/dist/index.js +57 -0
- package/dist/overlay-registry.d.ts +3 -1
- package/dist/overlay-registry.js +3 -1
- package/dist/overlays.js +5 -2
- package/dist/perf-overlay.d.ts +9 -0
- package/dist/perf-overlay.js +125 -0
- package/dist/plugin.d.ts +11 -1
- package/dist/plugin.js +33 -2
- package/dist/post-common.d.ts +20 -2
- package/dist/post-common.js +79 -2
- package/dist/post-nodes.d.ts +5 -0
- package/dist/post-nodes.js +136 -104
- package/dist/post-scene.d.ts +130 -0
- package/dist/post-scene.js +164 -0
- package/dist/post-shaders.js +41 -63
- package/dist/post.js +11 -1
- package/dist/ribbons-plugin.d.ts +5 -0
- package/dist/ribbons-plugin.js +59 -0
- package/dist/ribbons.d.ts +44 -0
- package/dist/ribbons.js +288 -0
- package/dist/scenario.d.ts +70 -0
- package/dist/scenario.js +125 -0
- package/dist/shaders.js +58 -5
- package/dist/shadows.d.ts +2 -1
- package/dist/shadows.js +3 -1
- package/dist/standard.js +6 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +6 -0
- package/dist/timer.d.ts +16 -1
- package/dist/timer.js +66 -19
- package/dist/vfx-draw.d.ts +37 -0
- package/dist/vfx-draw.js +291 -0
- package/dist/vfx-state.d.ts +42 -0
- package/dist/vfx-state.js +81 -0
- package/dist/vfx.d.ts +69 -0
- package/dist/vfx.js +103 -0
- package/package.json +19 -11
package/dist/ribbons.js
ADDED
|
@@ -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;
|
package/dist/scenario.js
ADDED
|
@@ -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
|
-
|
|
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. */
|