@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,74 @@
1
+ import { type World } from '@aethervtt/shard-core';
2
+ /**
3
+ * Per-pass GPU cost by ablation (0075). On tile-based GPUs passes overlap and a pass's timestamps
4
+ * include waiting for earlier work, so per-pass times add up to more than the frame. `gpu:frame`
5
+ * (first pass start to last pass end) stays true, so a pass's cost is how much `gpu:frame` drops
6
+ * with the pass disabled.
7
+ */
8
+ export interface AblationOptions {
9
+ /** Render-graph nodes to measure, each on its own. */
10
+ passes: readonly string[];
11
+ /** Frames measured per pass and round (the median is kept). Default 120. */
12
+ frames?: number;
13
+ /** Rounds, each measuring every pass and the baseline; the median over rounds is kept. Default 5. */
14
+ rounds?: number;
15
+ /** Frames dropped after each change, while timings of the previous state land. Default 4. */
16
+ settle?: number;
17
+ /** Also measure every pass disabled at once. */
18
+ together?: boolean;
19
+ /**
20
+ * Names in `passes` that stand for several nodes, disabled together (0075's scenario tests ablate
21
+ * a slice: every node whose `gpu:<node>` its key covers).
22
+ */
23
+ groups?: Readonly<Record<string, readonly string[]>>;
24
+ }
25
+ export interface PassCost {
26
+ /** The node, or every node joined with '+' for `together`. */
27
+ pass: string;
28
+ /** Median over rounds of baseline `gpu:frame` minus `gpu:frame` with the pass disabled, in ms. */
29
+ ms: number;
30
+ /** Each round's difference. */
31
+ rounds: number[];
32
+ }
33
+ export interface AblationResult {
34
+ /** Median `gpu:frame` with every pass enabled, in ms. */
35
+ frameMs: number;
36
+ passes: PassCost[];
37
+ /** Every pass disabled at once, with `together`. */
38
+ together?: PassCost;
39
+ frames: number;
40
+ rounds: number;
41
+ /** GPU frame timings read, all states together. */
42
+ samples: number;
43
+ }
44
+ export interface PassCostsData {
45
+ /**
46
+ * The latest ablation in this app, and when it finished (`Time.elapsed`, s). The `perf` overlay
47
+ * and the playground HUD rank passes by it where their timestamps overlap (0075).
48
+ */
49
+ latest: (AblationResult & {
50
+ at: number;
51
+ }) | undefined;
52
+ }
53
+ /** The latest `perf.ablate` result, where the overlay, the HUD and `perf.describe` read it. */
54
+ export declare const PassCosts: import("@aethervtt/shard-core").ResourceDef<PassCostsData>;
55
+ /** How a measurement drives the app: what to disable, and one frame's `gpu:frame`. */
56
+ export interface AblationDriver {
57
+ disable(passes: readonly string[]): void;
58
+ /** Renders a frame and resolves its `gpu:frame` in ms, or undefined when none landed. */
59
+ frame(): Promise<number | undefined>;
60
+ }
61
+ /**
62
+ * The measurement, apart from any app: each round measures the baseline, every pass disabled in
63
+ * turn (in reverse order on odd rounds), and the baseline again; a pass's difference in a round is
64
+ * the mean of the two baselines minus its median. Interleaving rounds cancels thermal drift, as
65
+ * `structure/src/cutaway.test.ts` does. Restores every pass before it returns or throws.
66
+ */
67
+ export declare function runAblation(driver: AblationDriver, options: AblationOptions): Promise<AblationResult>;
68
+ /**
69
+ * Ablates render-graph passes in a running app: `frame` produces one frame (rendering without
70
+ * advancing time, or waiting for the loop). Reads `gpu:frame` from the graph's `GpuTimer`, so the
71
+ * device needs `timestamp-query` (`render/gpu-timing-unavailable` otherwise). Unknown passes throw
72
+ * `render/unknown-node`.
73
+ */
74
+ export declare function ablatePasses(world: World, options: AblationOptions, frame: () => Promise<void> | void): Promise<AblationResult>;
@@ -0,0 +1,117 @@
1
+ import { defineResource, ShardError } from "@aethervtt/shard-core";
2
+ import { PerfProviders, Time } from "@aethervtt/shard-runtime";
3
+ import { Gpu, Graph } from "./plugin.js";
4
+ const PassCosts = defineResource("render/PassCosts", {
5
+ description: "The latest ablation's per-pass GPU costs (0075): what ranks passes where their timestamps overlap.",
6
+ init: () => ({ latest: void 0 })
7
+ });
8
+ function keep(world, result) {
9
+ const costs = world.initResource(PassCosts);
10
+ costs.latest = { ...result, at: world.tryResource(Time)?.elapsed ?? 0 };
11
+ const sections = world.initResource(PerfProviders).sections;
12
+ if (!sections.has("ablation")) {
13
+ sections.set("ablation", (w) => {
14
+ const latest = w.tryResource(PassCosts)?.latest;
15
+ if (!latest) return void 0;
16
+ return {
17
+ at: latest.at,
18
+ frameMs: latest.frameMs,
19
+ passes: [...latest.passes].sort((a, b) => b.ms - a.ms).map((p) => ({ pass: p.pass, ms: p.ms })),
20
+ ...latest.together ? { together: latest.together.ms } : {}
21
+ };
22
+ });
23
+ }
24
+ }
25
+ const median = (values) => {
26
+ if (values.length === 0) return Number.NaN;
27
+ const sorted = [...values].sort((a, b) => a - b);
28
+ const mid = sorted.length >> 1;
29
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
30
+ };
31
+ async function runAblation(driver, options) {
32
+ const frames = options.frames ?? 120;
33
+ const rounds = options.rounds ?? 5;
34
+ const settle = options.settle ?? 4;
35
+ const sets = options.passes.map((p) => [p]);
36
+ if (options.together && options.passes.length > 1) sets.push(options.passes);
37
+ let samples = 0;
38
+ const measure = async (disabled) => {
39
+ driver.disable(disabled);
40
+ for (let i = 0; i < settle; i++) await driver.frame();
41
+ const times = [];
42
+ for (let i = 0; i < frames; i++) {
43
+ const ms = await driver.frame();
44
+ if (ms !== void 0) times.push(ms);
45
+ }
46
+ samples += times.length;
47
+ return median(times);
48
+ };
49
+ const diffs = sets.map(() => []);
50
+ const baselines = [];
51
+ try {
52
+ for (let round = 0; round < rounds; round++) {
53
+ const before = await measure([]);
54
+ const order = sets.map((_, i) => i);
55
+ if (round % 2 === 1) order.reverse();
56
+ const off = /* @__PURE__ */ new Map();
57
+ for (const i of order) off.set(i, await measure(sets[i]));
58
+ const after = await measure([]);
59
+ const base = (before + after) / 2;
60
+ baselines.push(base);
61
+ for (let i = 0; i < sets.length; i++) diffs[i].push(base - off.get(i));
62
+ }
63
+ } finally {
64
+ driver.disable([]);
65
+ }
66
+ const costs = sets.map(
67
+ (set, i) => ({ pass: set.join("+"), ms: median(diffs[i]), rounds: diffs[i] })
68
+ );
69
+ const together = options.together && options.passes.length > 1 ? costs.pop() : void 0;
70
+ return {
71
+ frameMs: median(baselines),
72
+ passes: costs,
73
+ ...together ? { together } : {},
74
+ frames,
75
+ rounds,
76
+ samples
77
+ };
78
+ }
79
+ async function ablatePasses(world, options, frame) {
80
+ const graph = world.resource(Graph);
81
+ const gpu = world.resource(Gpu);
82
+ if (!graph.timer.enabled) {
83
+ throw new ShardError("render/gpu-timing-unavailable", "This device can't time GPU passes", {
84
+ hint: "Ablation reads gpu:frame, which needs the 'timestamp-query' feature."
85
+ });
86
+ }
87
+ if (options.passes.length === 0) {
88
+ throw new ShardError("render/unknown-node", "No passes to ablate", {
89
+ hint: `Name render graph nodes: ${graph.nodeNames().join(", ")}.`
90
+ });
91
+ }
92
+ const groups = options.groups;
93
+ const nodesOf = (passes) => groups ? passes.flatMap((p) => groups[p] ?? [p]) : passes;
94
+ graph.ablate(nodesOf(options.passes));
95
+ graph.ablate([]);
96
+ const timer = graph.timer;
97
+ const driver = {
98
+ disable: (passes) => graph.ablate(nodesOf(passes)),
99
+ async frame() {
100
+ const seen = timer.frameSamples;
101
+ await frame();
102
+ for (let i = 0; i < 8 && timer.frameSamples === seen; i++) {
103
+ await gpu.device.queue.onSubmittedWorkDone();
104
+ await new Promise((resolve) => setTimeout(resolve, 0));
105
+ }
106
+ return timer.frameSamples === seen ? void 0 : timer.frameMs;
107
+ }
108
+ };
109
+ const result = await runAblation(driver, options);
110
+ keep(world, result);
111
+ return result;
112
+ }
113
+ export {
114
+ PassCosts,
115
+ ablatePasses,
116
+ runAblation
117
+ };
@@ -7,10 +7,11 @@ import {
7
7
  environmentBakers
8
8
  } from "./environment.js";
9
9
  import { addRenderFeatures } from "./features.js";
10
- import { ForwardStateResource, sceneColor } from "./forward.js";
10
+ import { ForwardStateResource, sceneColor, viewBindGroup } from "./forward.js";
11
11
  import { RenderPhase } from "./graph.js";
12
12
  import { Gpu, Graph, Shaders, Views } from "./plugin.js";
13
13
  import { hasEffect, PostEffect } from "./post.js";
14
+ import { roundsRg11, targetsFor } from "./post-common.js";
14
15
  import { bindingDimension, depthReadEntry } from "./tier.js";
15
16
  import { cameraOf } from "./view.js";
16
17
  const LUT_LAYERS = 8;
@@ -371,6 +372,21 @@ function layouts(gpu, s) {
371
372
  { binding: 7, visibility: F, texture: { sampleType: "float", viewDimension: "3d" } }
372
373
  ]
373
374
  }),
375
+ // The composite with fog folded in: fog's parameters after the haze's bindings.
376
+ compositeFog: gpu.layouts.bindGroupLayout({
377
+ label: "atmosphere/composite-fog",
378
+ entries: [
379
+ { binding: 0, visibility: F, texture: { sampleType: "unfilterable-float" } },
380
+ depthReadEntry(gpu, 1, F),
381
+ uniform(2, F),
382
+ array(3, F),
383
+ array(4, F),
384
+ filtering(5, F),
385
+ { binding: 6, visibility: F, texture: { sampleType: "float", viewDimension: "3d" } },
386
+ { binding: 7, visibility: F, texture: { sampleType: "float", viewDimension: "3d" } },
387
+ uniform(8, F)
388
+ ]
389
+ }),
374
390
  viewOnly: gpu.layouts.bindGroupLayout({
375
391
  label: "atmosphere/view",
376
392
  entries: [uniform(0, GPUShaderStage.VERTEX | F)]
@@ -692,12 +708,31 @@ function skyNode() {
692
708
  }
693
709
  };
694
710
  }
711
+ const fogScratch = new Float32Array(8);
712
+ const FOG_COMPOSITE_KEYS = [
713
+ "composite-fog",
714
+ "composite-fog/alpha",
715
+ "composite-fog/interior",
716
+ "composite-fog/alpha/interior",
717
+ "composite-fog/rg11",
718
+ "composite-fog/alpha/rg11",
719
+ "composite-fog/interior/rg11",
720
+ "composite-fog/alpha/interior/rg11"
721
+ ];
695
722
  function compositeNode() {
723
+ const fogBuffers = /* @__PURE__ */ new Map();
696
724
  return {
697
725
  kind: "render",
698
726
  phase: RenderPhase.Post,
699
727
  enabled: hasEffect(PostEffect.Atmosphere),
700
- reads: ["atmosphere-in", "depth", "atmosphere-luts", "atmosphere-view"],
728
+ reads: [
729
+ "atmosphere-in",
730
+ "depth",
731
+ "atmosphere-luts",
732
+ "atmosphere-view",
733
+ "environment",
734
+ "clusters"
735
+ ],
701
736
  writes: ["atmosphere-out"],
702
737
  color: [{ resource: "atmosphere-out", clear: { r: 0, g: 0, b: 0, a: 1 } }],
703
738
  run: (ctx) => {
@@ -705,30 +740,43 @@ function compositeNode() {
705
740
  const ca = ctx.view.data.atmosphere;
706
741
  const s = ctx.world.resource(AtmosphereGpuResource);
707
742
  const c = cam ? s.cameras.get(cam.entity) : void 0;
708
- const pv = ctx.world.resource(ForwardStateResource).views.get(ctx.view.name);
743
+ const forward = ctx.world.resource(ForwardStateResource);
744
+ const pv = forward.views.get(ctx.view.name);
709
745
  if (!cam || !ca || !c || !pv) return;
710
746
  const gpu = ctx.gpu;
711
747
  const l = layouts(gpu, s);
712
- let pipeline = s.pipelines.get("composite");
748
+ const fog = (cam.post.effects & PostEffect.Fog) !== 0;
749
+ const alpha = cam.alphaOutput;
750
+ const out = ctx.texture("atmosphere-out");
751
+ const rg11 = out.format === "rg11b10ufloat";
752
+ const name = fog ? FOG_COMPOSITE_KEYS[(alpha ? 1 : 0) + (forward.interiorMode !== 0 ? 2 : 0) + (rg11 ? 4 : 0)] : rg11 ? "composite/rg11" : "composite";
753
+ let pipeline = s.pipelines.get(name);
713
754
  if (!pipeline) {
714
755
  const shaders = ctx.world.resource(Shaders);
715
- const fs = shaders.module(gpu, { root: "shard::atmosphere::composite" });
756
+ const fs = shaders.module(gpu, {
757
+ root: "shard::atmosphere::composite",
758
+ defines: { FOG: fog, TRANSPARENT: fog && alpha, RG11_ROUND: roundsRg11(gpu, out) }
759
+ });
716
760
  const vs = shaders.module(gpu, { root: "shard::fullscreen" });
717
761
  if (!fs || !vs) {
718
762
  gpu.pipelines.skipped++;
719
763
  return;
720
764
  }
721
765
  pipeline = gpu.pipelines.render({
722
- label: "atmosphere/composite",
766
+ label: `atmosphere/${name}`,
723
767
  layout: gpu.layouts.pipelineLayout({
724
- label: "atmosphere/composite",
725
- bindGroupLayouts: [l.viewOnly, l.composite]
768
+ label: `atmosphere/${name}`,
769
+ bindGroupLayouts: fog ? [forward.layouts.view, l.compositeFog] : [l.viewOnly, l.composite]
726
770
  }),
727
771
  vertex: { module: vs, entryPoint: "vs" },
728
- fragment: { module: fs, entryPoint: "fs", targets: [{ format: "rgba16float" }] }
772
+ fragment: {
773
+ module: fs,
774
+ entryPoint: fog ? "fs_fog" : "fs",
775
+ targets: targetsFor(out)
776
+ }
729
777
  });
730
778
  if (!pipeline) return;
731
- s.pipelines.set("composite", pipeline);
779
+ s.pipelines.set(name, pipeline);
732
780
  }
733
781
  const input = ctx.texture("atmosphere-in");
734
782
  const depth = ctx.texture("depth");
@@ -736,6 +784,48 @@ function compositeNode() {
736
784
  const inside = ca.inside && c.computed === s.frame;
737
785
  const scatter = inside && c.scatter ? c.scatter : e.d3;
738
786
  const trans = inside && c.transmittance ? c.transmittance : e.d3;
787
+ const pass = ctx.renderPass;
788
+ pass.setPipeline(pipeline);
789
+ if (fog) {
790
+ let buffer = fogBuffers.get(cam.entity);
791
+ if (!buffer) {
792
+ buffer = gpu.device.createBuffer({
793
+ label: `${ctx.view.name}/composite-fog`,
794
+ size: 32,
795
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
796
+ });
797
+ fogBuffers.set(cam.entity, buffer);
798
+ }
799
+ const f = cam.post.fog;
800
+ fogScratch.set(f.color, 0);
801
+ fogScratch[4] = f.density;
802
+ fogScratch[5] = f.heightFalloff;
803
+ fogScratch[6] = f.start;
804
+ fogScratch[7] = f.sunScattering;
805
+ gpu.device.queue.writeBuffer(buffer, 0, fogScratch);
806
+ const fogBuffer = buffer;
807
+ const group2 = s.group(
808
+ gpu,
809
+ `${cam.entity}/composite-fog`,
810
+ `${gpu.generation}/${idOf(input)}/${idOf(depth)}/${c.uniform.version}/${idOf(scatter)}/${idOf(trans)}/${idOf(s.transmittance)}/${idOf(fogBuffer)}`,
811
+ l.compositeFog,
812
+ () => [
813
+ { binding: 0, resource: input.createView() },
814
+ { binding: 1, resource: depth.createView() },
815
+ { binding: 2, resource: { buffer: c.uniform.buffer } },
816
+ { binding: 3, resource: s.transmittanceArray },
817
+ { binding: 4, resource: s.multiscatterArray },
818
+ { binding: 5, resource: s.clamp },
819
+ { binding: 6, resource: scatter.createView() },
820
+ { binding: 7, resource: trans.createView() },
821
+ { binding: 8, resource: { buffer: fogBuffer } }
822
+ ]
823
+ );
824
+ pass.setBindGroup(0, viewBindGroup(gpu, ctx.world, pv, cam));
825
+ pass.setBindGroup(1, group2);
826
+ pass.draw(3);
827
+ return;
828
+ }
739
829
  const group = s.group(
740
830
  gpu,
741
831
  `${cam.entity}/composite`,
@@ -759,8 +849,6 @@ function compositeNode() {
759
849
  l.viewOnly,
760
850
  () => [{ binding: 0, resource: { buffer: pv.uniform.buffer } }]
761
851
  );
762
- const pass = ctx.renderPass;
763
- pass.setPipeline(pipeline);
764
852
  pass.setBindGroup(0, viewGroup);
765
853
  pass.setBindGroup(1, group);
766
854
  pass.draw(3);
@@ -518,8 +518,9 @@ struct SkyOutput {
518
518
  }`,
519
519
  "shard::atmosphere::composite": `
520
520
  import shard::view::view;
521
- import shard::post::common::uv_of;
521
+ import shard::post::common::{ uv_of, post_out };
522
522
  import shard::atmosphere::{ AtmosphereView, KCD, integrate, froxel_slice, relative_at };
523
+ @if(FOG) import shard::post::fog::common::{ FogParams, fog_apply };
523
524
 
524
525
  @group(1) @binding(0) var input: texture_2d<f32>;
525
526
  @group(1) @binding(1) var depth_texture: texture_depth_2d;
@@ -529,16 +530,15 @@ import shard::atmosphere::{ AtmosphereView, KCD, integrate, froxel_slice, relati
529
530
  @group(1) @binding(5) var lut_sampler: sampler;
530
531
  @group(1) @binding(6) var ap_scatter: texture_3d<f32>;
531
532
  @group(1) @binding(7) var ap_transmittance: texture_3d<f32>;
533
+ /** A view with fog too: fog follows in the same pass (\`fs_fog\`). */
534
+ @if(FOG) @group(1) @binding(8) var<uniform> fog: FogParams;
532
535
 
533
536
  /**
534
537
  * Aerial perspective on geometry: color \xD7 transmittance + in-scattering for every atmosphere the
535
538
  * view ray crosses before the surface, far to near. The primary reads its froxels within their
536
539
  * range; beyond it (terrain seen from orbit), and for the others, the ray is marched.
537
540
  */
538
- @fragment fn fs(@builtin(position) frag: vec4f) -> @location(0) vec4f {
539
- let px = vec2i(frag.xy);
540
- let c = textureLoad(input, px, 0);
541
- let depth = textureLoad(depth_texture, px, 0);
541
+ fn haze(frag: vec4f, c: vec4f, depth: f32) -> vec4f {
542
542
  let count = u32(atmo.info.x);
543
543
  if (depth <= 0.0 || count == 0u) { return c; }
544
544
  let uv = uv_of(frag.xy);
@@ -568,6 +568,18 @@ import shard::atmosphere::{ AtmosphereView, KCD, integrate, froxel_slice, relati
568
568
  }
569
569
  }
570
570
  return vec4f(min(color, vec3f(60000.0)), c.a);
571
+ }
572
+
573
+ @fragment fn fs(@builtin(position) frag: vec4f) -> @location(0) vec4f {
574
+ let px = vec2i(frag.xy);
575
+ return post_out(haze(frag, textureLoad(input, px, 0), textureLoad(depth_texture, px, 0)));
576
+ }
577
+
578
+ /** Aerial perspective, then height fog: the view's fog pass folded into this one. */
579
+ @if(FOG) @fragment fn fs_fog(@builtin(position) frag: vec4f) -> @location(0) vec4f {
580
+ let px = vec2i(frag.xy);
581
+ let depth = textureLoad(depth_texture, px, 0);
582
+ return post_out(fog_apply(haze(frag, textureLoad(input, px, 0), depth), uv_of(frag.xy), depth, fog));
571
583
  }`,
572
584
  "shard::atmosphere::bake": `
573
585
  import shard::env::common::cube_dir;
@@ -20,6 +20,6 @@ export declare function meterLayout(gpu: GpuContext): GPUBindGroupLayout;
20
20
  * Renders the meter image and, when a readback is free, copies it out. Returns the readback that
21
21
  * will hold it, or undefined when all are still mapping (this frame isn't metered).
22
22
  */
23
- export declare function renderMeter(ctx: NodeContext, m: MeterView, pipeline: GPURenderPipeline, group: GPUBindGroup, range: Float32Array): number | undefined;
23
+ export declare function renderMeter(ctx: NodeContext, m: MeterView, pipeline: GPURenderPipeline, group: GPUBindGroup, range: Float32Array, glow: Float32Array): number | undefined;
24
24
  /** The meter image's samples binned into the full tier's histogram: weight into its EV bin. */
25
25
  export declare function binMeter(bytes: ArrayBuffer, minEv: number, binsPerEv: number, bins: Uint32Array): Uint32Array;
@@ -5,22 +5,32 @@ const BASELINE_EXPOSURE_SHADERS = {
5
5
  "shard::post::baseline::meter": `
6
6
  import shard::view::view;
7
7
  import shard::color::luminance;
8
+ @if(BLOOM) import shard::post::glow::add_glow;
8
9
 
9
10
  struct Meter {
10
11
  /** EV of bin 0, bins per EV, metering mode, 0. */
11
12
  range: vec4f,
12
13
  /** The meter image's size. */
13
14
  size: vec4f,
15
+ /** Bloom, which the tonemap adds to the image: intensity, level count, mix (1) or add (0). */
16
+ glow: vec4f,
14
17
  }
15
18
 
16
19
  @group(0) @binding(1) var input: texture_2d<f32>;
17
20
  @group(0) @binding(2) var<uniform> meter: Meter;
21
+ @if(BLOOM) @group(0) @binding(3) var bloom: texture_2d<f32>;
22
+ @if(BLOOM) @group(0) @binding(4) var bloom_sampler: sampler;
18
23
 
19
24
  /** One sample: the EV100 of the input pixel under it, and its metering weight (of 16). */
20
25
  @fragment fn main(@builtin(position) p: vec4f) -> @location(0) vec4f {
21
26
  let size = textureDimensions(input);
22
27
  let px = min(vec2u((floor(p.xy) + 0.5) / meter.size.xy * vec2f(size)), size - 1u);
23
- let c = textureLoad(input, vec2i(px), 0).rgb;
28
+ var c = textureLoad(input, vec2i(px), 0).rgb;
29
+ // What the tonemap shows: the image with bloom's glow over it.
30
+ @if(BLOOM) {
31
+ let uv = (vec2f(px) + 0.5) / vec2f(size);
32
+ c = add_glow(c, textureSampleLevel(bloom, bloom_sampler, uv, 0.0).rgb, meter.glow);
33
+ }
24
34
  let l = luminance(c) / max(view.exposure, 1e-20);
25
35
  let ev = log2(max(l, 1e-10) * 8.0);
26
36
  let q = vec2f(px) / vec2f(size) * 2.0 - 1.0;
@@ -44,7 +54,7 @@ function meterView(gpu, name, readbacks) {
44
54
  view: target.createView(),
45
55
  params: gpu.device.createBuffer({
46
56
  label: `${name}/exposure-meter`,
47
- size: 32,
57
+ size: 48,
48
58
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
49
59
  }),
50
60
  readbacks: Array.from(
@@ -68,15 +78,18 @@ function meterLayout(gpu) {
68
78
  entries: [
69
79
  { binding: 0, visibility: F, buffer: { type: "uniform" } },
70
80
  { binding: 1, visibility: F, texture: { sampleType: "unfilterable-float" } },
71
- { binding: 2, visibility: F, buffer: { type: "uniform" } }
81
+ { binding: 2, visibility: F, buffer: { type: "uniform" } },
82
+ { binding: 3, visibility: F, texture: { sampleType: "float" } },
83
+ { binding: 4, visibility: F, sampler: { type: "filtering" } }
72
84
  ]
73
85
  });
74
86
  }
75
- const params = new Float32Array(8);
76
- function renderMeter(ctx, m, pipeline, group, range) {
87
+ const params = new Float32Array(12);
88
+ function renderMeter(ctx, m, pipeline, group, range, glow) {
77
89
  params.set(range, 0);
78
90
  params[4] = METER_WIDTH;
79
91
  params[5] = METER_HEIGHT;
92
+ params.set(glow, 8);
80
93
  ctx.gpu.device.queue.writeBuffer(m.params, 0, params);
81
94
  const pass = ctx.encoder.beginRenderPass({
82
95
  label: `${ctx.view.name}/exposure`,
@@ -0,0 +1,21 @@
1
+ import { type World } from '@aethervtt/shard-core';
2
+ export interface CrowdOptions {
3
+ /** Instances (default 200 000). */
4
+ count?: number;
5
+ /** Every instance at full detail, no LOD (the playground's `?nolod`). */
6
+ flat?: boolean;
7
+ }
8
+ /**
9
+ * The crowd's turntable: 160 m out and 40 m up, looking at the field's centre, `angle` radians
10
+ * round (the playground turns it 2.86° a second).
11
+ */
12
+ export declare function crowdCamera(angle: number): {
13
+ eye: [number, number, number];
14
+ target: [number, number, number];
15
+ };
16
+ /**
17
+ * 200k static instances of 20 mesh types over a 600 m field, under a sun with 4 shadow cascades.
18
+ * Most have 3 LOD levels; every fifth is a small prop hidden past 120 m. The camera is the
19
+ * caller's (`crowdCamera` places the turntable).
20
+ */
21
+ export declare function spawnCrowd(world: World, options?: CrowdOptions): void;
package/dist/crowd.js ADDED
@@ -0,0 +1,121 @@
1
+ import { quat, Rng } from "@aethervtt/shard-core";
2
+ import {
3
+ box,
4
+ capsule,
5
+ cone,
6
+ cylinder,
7
+ plane,
8
+ sphere,
9
+ torus
10
+ } from "@aethervtt/shard-mesh";
11
+ import { Transform } from "@aethervtt/shard-transform";
12
+ import { MaterialAsset, Materials, Meshes } from "./assets.js";
13
+ import { Lod, Mesh3d, MeshMaterial, VisibilityRange } from "./instances.js";
14
+ import { DirectionalLight } from "./lights.js";
15
+ function kinds() {
16
+ const out = [];
17
+ for (const segments of [8, 12, 16, 24]) {
18
+ out.push([sphere({ radius: 0.5, segments }), sphere({ radius: 0.5, segments: 6 })]);
19
+ }
20
+ for (const y of [0.5, 0.8, 1.2]) {
21
+ const m = box({ x: 0.8, y, z: 0.8 });
22
+ out.push([m, m]);
23
+ }
24
+ for (const segments of [6, 12, 24]) {
25
+ out.push([
26
+ cylinder({ radius: 0.4, height: 1, segments }),
27
+ cylinder({ radius: 0.4, height: 1, segments: 5 })
28
+ ]);
29
+ }
30
+ for (const segments of [6, 16]) {
31
+ out.push([
32
+ cone({ radius: 0.5, height: 1.2, segments }),
33
+ cone({ radius: 0.5, height: 1.2, segments: 4 })
34
+ ]);
35
+ }
36
+ for (const segments of [8, 16]) {
37
+ out.push([
38
+ capsule({ radius: 0.3, height: 1.2, segments }),
39
+ capsule({ radius: 0.3, height: 1.2, segments: 4, rings: 2 })
40
+ ]);
41
+ }
42
+ for (const tube of [0.2, 0.1]) {
43
+ out.push([
44
+ torus({ radius: 0.4, tube }),
45
+ torus({ radius: 0.4, tube, radialSegments: 4, tubularSegments: 8 })
46
+ ]);
47
+ }
48
+ for (const y of [0.3, 0.6, 1]) {
49
+ const m = box({ x: 0.5, y, z: 1.2 });
50
+ out.push([m, m]);
51
+ }
52
+ const beam = box({ x: 1.5, y: 0.3, z: 0.3 });
53
+ out.push([beam, beam]);
54
+ return out;
55
+ }
56
+ function crowdCamera(angle) {
57
+ return { eye: [Math.sin(angle) * 160, 40, Math.cos(angle) * 160], target: [0, 0, 0] };
58
+ }
59
+ function spawnCrowd(world, options = {}) {
60
+ const count = options.count ?? 2e5;
61
+ const flat = options.flat ?? false;
62
+ world.spawn(
63
+ [DirectionalLight, { illuminance: 6e4, shadows: true }],
64
+ [Transform, { rotation: quat.fromEuler([0, 0, 0, 1], -0.8, 0.7, 0) }]
65
+ );
66
+ const meshes = world.resource(Meshes);
67
+ const materials = world.resource(Materials);
68
+ const rng = new Rng(11);
69
+ const stand = meshes.add(box({ x: 0.8, y: 0.8, z: 0.8 }));
70
+ const types = kinds().map(([hi, lo]) => {
71
+ const mesh = meshes.add(hi);
72
+ return {
73
+ mesh,
74
+ levels: [
75
+ { mesh, screenSize: 0.04 },
76
+ { mesh: meshes.add(lo), screenSize: 0.01 },
77
+ { mesh: stand, screenSize: 2e-3 }
78
+ ]
79
+ };
80
+ });
81
+ const looks = Array.from(
82
+ { length: 8 },
83
+ () => materials.add(
84
+ new MaterialAsset({
85
+ baseColor: [rng.range(0.2, 0.9), rng.range(0.2, 0.9), rng.range(0.2, 0.9), 1],
86
+ roughness: rng.range(0.2, 0.9)
87
+ })
88
+ )
89
+ );
90
+ world.spawn(
91
+ [Mesh3d, { mesh: meshes.add(plane({ size: 700 })) }],
92
+ [MeshMaterial, { material: looks[0] }],
93
+ Transform
94
+ );
95
+ for (let i = 0; i < count; i++) {
96
+ const s = rng.range(0.6, 1.8);
97
+ const kind = types[i % types.length];
98
+ const common = [
99
+ [MeshMaterial, { material: looks[i % looks.length] }],
100
+ [
101
+ Transform,
102
+ {
103
+ translation: [rng.range(-300, 300), s * 0.5, rng.range(-300, 300)],
104
+ rotation: quat.fromEuler([0, 0, 0, 1], 0, rng.float() * 6.28, 0),
105
+ scale: [s, s, s]
106
+ }
107
+ ]
108
+ ];
109
+ if (flat) {
110
+ world.spawn([Mesh3d, { mesh: kind.mesh }], ...common);
111
+ } else if (i % 5 === 0) {
112
+ world.spawn([Mesh3d, { mesh: kind.mesh }], [VisibilityRange, { end: 120 }], ...common);
113
+ } else {
114
+ world.spawn([Mesh3d, { mesh: kind.mesh }], [Lod, { levels: kind.levels }], ...common);
115
+ }
116
+ }
117
+ }
118
+ export {
119
+ crowdCamera,
120
+ spawnCrowd
121
+ };
@@ -0,0 +1,5 @@
1
+ import { VfxRenderer } from './vfx-draw.js';
2
+ export declare const DecalGpu: import("@aethervtt/shard-core").ResourceDef<{
3
+ renderer?: VfxRenderer;
4
+ }>;
5
+ export declare const decalsPlugin: import("@aethervtt/shard-runtime").Plugin;