@woosh/meep-engine 3.11.0 → 3.11.2
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/package.json +1 -1
- package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
- package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +294 -290
- package/src/engine/graphics3/ParticipatingMedia.d.ts +111 -0
- package/src/engine/graphics3/ParticipatingMedia.d.ts.map +1 -0
- package/src/engine/graphics3/ParticipatingMedia.js +203 -0
- package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts +34 -0
- package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts.map +1 -0
- package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.js +73 -0
- package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +87 -0
- package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -0
- package/src/engine/graphics3/ParticipatingMediaSystem3.js +184 -0
- package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
- package/src/shade/playground/volumetrics_froxel/README.md +191 -0
- package/src/shade/playground/volumetrics_froxel/index.html +270 -0
- package/src/shade/playground/volumetrics_froxel/main.d.ts +2 -0
- package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -0
- package/src/shade/playground/volumetrics_froxel/main.js +917 -0
- package/src/shade/playground/volumetrics_froxel/measure.d.ts +119 -0
- package/src/shade/playground/volumetrics_froxel/measure.d.ts.map +1 -0
- package/src/shade/playground/volumetrics_froxel/measure.js +559 -0
- package/src/shade/renderer/Renderer.d.ts +13 -0
- package/src/shade/renderer/Renderer.d.ts.map +1 -1
- package/src/shade/renderer/Renderer.js +45 -18
- package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
- package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts +14 -13
- package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts.map +1 -1
- package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.js +133 -129
- package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +480 -315
- package/src/shade/renderer/volumetrics/NOTES.md +691 -9
- package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts +5 -0
- package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.js +21 -2
- package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts +26 -0
- package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/SceneVolumetrics.js +64 -21
- package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.js +84 -28
- package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts +98 -0
- package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.js +111 -0
- package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts +84 -0
- package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.js +252 -0
- package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts +21 -0
- package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.js +47 -0
- package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.js +75 -64
- package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts +37 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.js +56 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts +43 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.js +145 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts +20 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.js +48 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts +38 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.js +107 -0
- package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +4 -1
- package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +577 -465
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +418 -324
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.js +14 -3
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts +8 -0
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.js +174 -0
- package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.js +48 -36
- package/src/shade/wgsl/emulator/CPUBitmapData.d.ts +25 -0
- package/src/shade/wgsl/emulator/CPUBitmapData.d.ts.map +1 -1
- package/src/shade/wgsl/emulator/CPUBitmapData.js +33 -0
- package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +2221 -2215
- package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
- package/src/shade/wgsl/emulator/WGSLLanguageCore.js +1297 -1117
- package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts +69 -0
- package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts.map +1 -0
- package/src/shade/wgsl/emulator/cpu_texture_gather.js +148 -0
- package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts +16 -0
- package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts.map +1 -1
- package/src/shade/wgsl/emulator/cpu_texture_sample.js +176 -172
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { assert } from "../../core/assert.js";
|
|
2
|
+
import { isArrayEqualStrict } from "../../core/collection/array/isArrayEqualStrict.js";
|
|
3
|
+
import { ParticipatingMediaVolume } from "../../shade/renderer/volumetrics/ParticipatingMediaVolume.js";
|
|
4
|
+
import { System } from "../ecs/System.js";
|
|
5
|
+
import { Transform } from "../ecs/transform/Transform.js";
|
|
6
|
+
import { ParticipatingMedia } from "./ParticipatingMedia.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mirrors fog, smoke and cloud entities into Shade's volumetrics.
|
|
10
|
+
*
|
|
11
|
+
* The same shape as {@link LightSystem3}: the ECS says what exists and where, and this keeps the
|
|
12
|
+
* renderer's own objects in step. A {@link ParticipatingMediaVolume} is a unit cube posed by a
|
|
13
|
+
* transform, so an entity's `Transform` is the volume — position, size and orientation with no
|
|
14
|
+
* translation step in between.
|
|
15
|
+
*
|
|
16
|
+
* **The transform is taken as world-space, with no hierarchy applied**, which is what the volume
|
|
17
|
+
* model can express: a volume is an oriented box in the scene and Shade has no parent chain to walk
|
|
18
|
+
* for it. Parenting a fog volume to a moving entity therefore leaves the fog where the child's own
|
|
19
|
+
* transform puts it. The lights are placed the same way, for the same reason.
|
|
20
|
+
*
|
|
21
|
+
* **Changes are polled once a frame rather than subscribed to.** A `Transform` announces itself and
|
|
22
|
+
* nothing else here does — a density tuned by an editor slider, a particle spec swapped wholesale
|
|
23
|
+
* for a different Mie solution — so subscribing would cover one field in three and the other two
|
|
24
|
+
* would still have to be polled. Two paths that have to agree, where one does the whole job. It
|
|
25
|
+
* costs about two dozen float comparisons per volume per frame and a scene holds volumes in the
|
|
26
|
+
* tens, so the comparison is cheaper than the signal plumbing it replaces. The price is latency: a
|
|
27
|
+
* change is visible within a frame rather than instantly.
|
|
28
|
+
*
|
|
29
|
+
* **Nothing is uploaded unless something actually changed.** The GPU table is repacked whole
|
|
30
|
+
* whenever {@link SceneVolumetrics.version} moves, so an unconditional copy every frame would
|
|
31
|
+
* re-upload every volume in the scene forever. The comparison is what decides, and on almost every
|
|
32
|
+
* frame it decides nothing has to happen.
|
|
33
|
+
*
|
|
34
|
+
* @see ParticipatingMedia the component
|
|
35
|
+
* @see SceneVolumetrics what the volumes are handed to
|
|
36
|
+
*/
|
|
37
|
+
export class ParticipatingMediaSystem3 extends System {
|
|
38
|
+
|
|
39
|
+
dependencies = [ParticipatingMedia, Transform];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @type {GraphicsEngine3}
|
|
43
|
+
*/
|
|
44
|
+
#graphics;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @type {Scene}
|
|
48
|
+
*/
|
|
49
|
+
#scene;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @type {Map<number, {component: ParticipatingMedia, transform: Transform, volume: ParticipatingMediaVolume}>}
|
|
53
|
+
*/
|
|
54
|
+
#entries = new Map();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {GraphicsEngine3} graphics
|
|
58
|
+
* @param {Scene} scene
|
|
59
|
+
*/
|
|
60
|
+
constructor(graphics, scene) {
|
|
61
|
+
super();
|
|
62
|
+
|
|
63
|
+
assert.defined(graphics, 'graphics');
|
|
64
|
+
assert.defined(scene, 'scene');
|
|
65
|
+
assert.equal(scene.isScene, true, 'scene.isScene !== true');
|
|
66
|
+
|
|
67
|
+
this.#graphics = graphics;
|
|
68
|
+
this.#scene = scene;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async startup(entityManager) {
|
|
72
|
+
this.entityManager = entityManager;
|
|
73
|
+
|
|
74
|
+
this.#graphics.set_scene(this.#scene);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async shutdown(entityManager) {
|
|
78
|
+
for (const entity of Array.from(this.#entries.keys())) {
|
|
79
|
+
this.#drop(entity);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {ParticipatingMedia} component
|
|
85
|
+
* @param {Transform} transform
|
|
86
|
+
* @param {number} entity
|
|
87
|
+
*/
|
|
88
|
+
link(component, transform, entity) {
|
|
89
|
+
const volume = new ParticipatingMediaVolume();
|
|
90
|
+
|
|
91
|
+
participating_media_volume_write(volume, component, transform);
|
|
92
|
+
|
|
93
|
+
this.#scene.volumetrics.add(volume);
|
|
94
|
+
|
|
95
|
+
this.#entries.set(entity, { component, transform, volume });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {ParticipatingMedia} component
|
|
100
|
+
* @param {Transform} transform
|
|
101
|
+
* @param {number} entity
|
|
102
|
+
*/
|
|
103
|
+
unlink(component, transform, entity) {
|
|
104
|
+
this.#drop(entity);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {number} time_delta_seconds
|
|
109
|
+
*/
|
|
110
|
+
update(time_delta_seconds) {
|
|
111
|
+
const volumetrics = this.#scene.volumetrics;
|
|
112
|
+
|
|
113
|
+
for (const entry of this.#entries.values()) {
|
|
114
|
+
const volume = entry.volume;
|
|
115
|
+
|
|
116
|
+
if (participating_media_volume_is_current(volume, entry.component, entry.transform)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
participating_media_volume_write(volume, entry.component, entry.transform);
|
|
121
|
+
|
|
122
|
+
volumetrics.invalidate();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {number} entity
|
|
128
|
+
*/
|
|
129
|
+
#drop(entity) {
|
|
130
|
+
const entry = this.#entries.get(entity);
|
|
131
|
+
|
|
132
|
+
if (entry === undefined) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.#scene.volumetrics.remove(entry.volume);
|
|
137
|
+
|
|
138
|
+
this.#entries.delete(entity);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Copy what the component and its transform say onto the volume Shade will render.
|
|
144
|
+
*
|
|
145
|
+
* The particle spec is **copied, not referenced**: two entities are routinely built from the same
|
|
146
|
+
* blueprint and would otherwise share one spec, so tuning one volume's particle would silently
|
|
147
|
+
* retune the other's.
|
|
148
|
+
*
|
|
149
|
+
* @param {ParticipatingMediaVolume} volume Shade's
|
|
150
|
+
* @param {ParticipatingMedia} component meep's
|
|
151
|
+
* @param {Transform} transform where the box is
|
|
152
|
+
*/
|
|
153
|
+
export function participating_media_volume_write(volume, component, transform) {
|
|
154
|
+
volume.density = component.density;
|
|
155
|
+
volume.fade_distance = component.fade_distance;
|
|
156
|
+
|
|
157
|
+
volume.particle_spec.copy(component.particle_spec);
|
|
158
|
+
|
|
159
|
+
volume.transform.copy(transform);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the volume already says what the component and transform say.
|
|
164
|
+
*
|
|
165
|
+
* True on all but a handful of frames, which is the point of asking: a `false` here costs the whole
|
|
166
|
+
* scene's volumetrics table a trip to the GPU.
|
|
167
|
+
*
|
|
168
|
+
* **The transform is compared by its matrix rather than by `Transform.equals`**, because the matrix
|
|
169
|
+
* is the entire transform the GPU is given — the volume's position, rotation and scale are never
|
|
170
|
+
* read on their own. That makes this exactly the question worth asking, "would the upload differ",
|
|
171
|
+
* and it catches a transform posed by writing its matrix, which comparing position, rotation and
|
|
172
|
+
* scale would not.
|
|
173
|
+
*
|
|
174
|
+
* @param {ParticipatingMediaVolume} volume
|
|
175
|
+
* @param {ParticipatingMedia} component
|
|
176
|
+
* @param {Transform} transform
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function participating_media_volume_is_current(volume, component, transform) {
|
|
180
|
+
return volume.density === component.density
|
|
181
|
+
&& volume.fade_distance === component.fade_distance
|
|
182
|
+
&& isArrayEqualStrict(volume.transform.matrix, transform.matrix)
|
|
183
|
+
&& volume.particle_spec.equals(component.particle_spec);
|
|
184
|
+
}
|
|
@@ -64,11 +64,11 @@ export class FluidObstacleSystem extends System<any> {
|
|
|
64
64
|
/**
|
|
65
65
|
* @param {FluidComponent} fluid
|
|
66
66
|
*/
|
|
67
|
-
static "__#
|
|
67
|
+
static "__#356@#refresh_masks"(fluid: FluidComponent): void;
|
|
68
68
|
/**
|
|
69
69
|
* @param {FluidComponent} fluid
|
|
70
70
|
*/
|
|
71
|
-
static "__#
|
|
71
|
+
static "__#356@#clear_field"(fluid: FluidComponent): void;
|
|
72
72
|
/**
|
|
73
73
|
* Mark every cell of `fluid` whose centre lies within `inflation` of the
|
|
74
74
|
* posed shape as solid. Iteration is clipped to the shape's world AABB
|
|
@@ -80,7 +80,7 @@ export class FluidObstacleSystem extends System<any> {
|
|
|
80
80
|
* @param {number} inflation world-units SDF threshold
|
|
81
81
|
* @param {Float64Array} point length-3 scratch
|
|
82
82
|
*/
|
|
83
|
-
static "__#
|
|
83
|
+
static "__#356@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
|
|
84
84
|
/**
|
|
85
85
|
* Write the obstacle's translation velocity onto every face of every cell
|
|
86
86
|
* it voxelized — the moving-wall boundary condition. Runs AFTER the mask
|
|
@@ -100,7 +100,7 @@ export class FluidObstacleSystem extends System<any> {
|
|
|
100
100
|
* @param {number} wvy
|
|
101
101
|
* @param {number} wvz
|
|
102
102
|
*/
|
|
103
|
-
static "__#
|
|
103
|
+
static "__#356@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
|
|
104
104
|
constructor();
|
|
105
105
|
dependencies: (typeof FluidObstacle)[];
|
|
106
106
|
components_used: (ResourceAccessSpecification<typeof Transform> | ResourceAccessSpecification<typeof RigidBody> | ResourceAccessSpecification<typeof Collider> | ResourceAccessSpecification<typeof FluidComponent> | ResourceAccessSpecification<typeof FluidObstacle>)[];
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# volumetrics_froxel
|
|
2
|
+
|
|
3
|
+
Real participating media, a movable sun and a wide-FOV camera in front of the froxel volumetrics
|
|
4
|
+
pass.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm run dev --workspace @woosh/meep-engine
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
then open <http://localhost:5173/src/shade/playground/volumetrics_froxel/index.html>.
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
`shader_volumetrics_build_lighting` and the passes around it were reviewed against current practice.
|
|
15
|
+
Five correctness findings came out of it; four were fixed and one was closed as a documented
|
|
16
|
+
non-issue. All five were geometric, and geometry is the class of defect a unit test can prove but
|
|
17
|
+
not show — an emulator can assert that a sample lands at the wrong depth and still not tell you
|
|
18
|
+
whether that reads as a smear, a seam, or nothing at all. This page was where that got decided.
|
|
19
|
+
|
|
20
|
+
| finding | what it was | where the fix lives |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| C-01a | Planar view depth walked as a distance along a unit ray, so the froxel sample fell short by cos θ | `chunk_camera_ray_planar_depth_scale`, from the lighting pass |
|
|
23
|
+
| C-01b | The aerial pass integrated Beer's law over a planar depth difference rather than arc length | same chunk, from the aerial pass — this was the visible half |
|
|
24
|
+
| C-02 | Cell-averaged grids read with the boundary-sampled convention, a ~0.53-froxel bias | `chunk_volumetrics_froxel_content_offset` |
|
|
25
|
+
| C-03 | Shadow ray clipped against a frustum with no far plane, so a sun anywhere in the view cone got zero optical depth | `chunk_integrate_optical_depth` takes the metadata frustum |
|
|
26
|
+
| C-04 | The shadow march addressed the froxel grid with the aerial LUT's depth curve | `volumetrics_position_world_to_froxel_uvw` |
|
|
27
|
+
| C-05 | Adjacent columns carry independent jitter, so an XY fetch mixes depths | closed — zero-mean and TAA-absorbable; `volumetrics/NOTES.md` has the measurements and why the obvious fix makes it worse |
|
|
28
|
+
|
|
29
|
+
Those fixes are in the permanent path now and there is nothing left to A/B. What the page is for is
|
|
30
|
+
the *next* change here — the performance findings especially, since every one of them trades image
|
|
31
|
+
quality for time and none can be judged from a screenshot.
|
|
32
|
+
|
|
33
|
+
Q-01 was judged here and now ships unconditionally: every clustered point and spot light is occluded
|
|
34
|
+
by geometry, sampled with one nearest tap out of the shadow atlas. The two checkboxes that switched
|
|
35
|
+
that off, or swapped the tap for the deferred pass's filter kernels, are gone along with the metadata
|
|
36
|
+
word behind them — `volumetrics/NOTES.md` keeps the measurements that closed the question. The
|
|
37
|
+
torches are the local lights here: 130 point lights, every one of them a shadow caster, and **zero
|
|
38
|
+
spot lights**, so the spot half has no on-screen coverage on this page and is covered by
|
|
39
|
+
`shader_volumetrics_build_lighting_local_shadow.spec.js` instead. Adding a `SpotLight` from the
|
|
40
|
+
console is the way to look at it.
|
|
41
|
+
|
|
42
|
+
Q-04 went the same way and its switch is gone too. The sun's media self-shadowing is one fetch into
|
|
43
|
+
a ladder of three cascaded volumes built once a frame with their third axis on the sun, and that is
|
|
44
|
+
now the only path for it: the `__VOL_SUN_SHADOW` global and the checkbox over it are removed along
|
|
45
|
+
with the `sun_shadow_mode` word they drove, so nothing on this page reproduces the march/cascade
|
|
46
|
+
comparison any more. Restoring it means re-adding that word and the branch in
|
|
47
|
+
`shader_volumetrics_build_lighting` that reads it.
|
|
48
|
+
|
|
49
|
+
Worth knowing what it was, because it is the size of what the page can no longer show. Against a
|
|
50
|
+
march at `STEP_COUNT = 512` the cascades read within 1.2 mean luminance at every viewpoint here and
|
|
51
|
+
across 40 to 110 degrees of field of view, while sixteen steps read **+58** and **+70** at the two
|
|
52
|
+
poses beside the small fog boxes — the step there is eight units, the box is three thick, and the
|
|
53
|
+
march goes straight over it. They are also cheaper: 0.37 ms against 0.53 at 1080p, 0.81 against 1.52
|
|
54
|
+
at 2160p, per pass. `volumetrics/NOTES.md` has the whole measurement, including the one that says a
|
|
55
|
+
*single* world-uniform box could not have been sized correctly at any resolution — every shape
|
|
56
|
+
measured has a field of view at which it is 28 to 50 luminance from the truth, and which one that is
|
|
57
|
+
moves with the resolution.
|
|
58
|
+
|
|
59
|
+
The march itself is still in the tree and still runs: every point and spot light uses it, and so
|
|
60
|
+
would a second directional light. Only the first one is amortized, because there is one ladder of
|
|
61
|
+
boxes and it is aligned to one direction.
|
|
62
|
+
|
|
63
|
+
## Measuring, rather than eyeballing
|
|
64
|
+
|
|
65
|
+
This pass is stochastic and temporally filtered, so most changes to it move the image by less than
|
|
66
|
+
the frame-to-frame noise. A screenshot pair is not evidence. Four traps, each of which produced a
|
|
67
|
+
confident wrong answer during the work above:
|
|
68
|
+
|
|
69
|
+
- **Auto exposure.** It keeps adapting for seconds after any change, so two captures of the
|
|
70
|
+
*identical* setting differed in mean luminance by more than the change being judged. Off by
|
|
71
|
+
default here; `renderer.exposure_compensation` sets the fixed value.
|
|
72
|
+
- **Per-frame jitter.** The column jitter and the sample RNG both key on `view.frame_index`, so
|
|
73
|
+
consecutive frames genuinely differ. Average 18+ frames per capture.
|
|
74
|
+
- **No noise floor, no result.** Capture the *same* setting twice and difference those first. If the
|
|
75
|
+
change's delta is not clearly above that, nothing has been shown.
|
|
76
|
+
- **The noise floor has to pay the same costs the signal does.** Anything that calls
|
|
77
|
+
`indicate_view_change()` resets temporal accumulation, so a capture taken right after one carries
|
|
78
|
+
a re-convergence transient that a back-to-back capture does not. Measuring the floor without that
|
|
79
|
+
in the middle inflates every result — it made C-04 fail a test it should have passed. Capture as
|
|
80
|
+
*change → settle ~45 frames → average*, and take the floor by making an identical no-op change so
|
|
81
|
+
both paths cost the same.
|
|
82
|
+
|
|
83
|
+
- **Frame time here is presentation-throttled, not GPU-bound.** At 1920×1080 every setting reads
|
|
84
|
+
~16.5 ms, including settings that do obviously different amounts of work — `ms_octaves` at 1 and at
|
|
85
|
+
8 come back within noise of each other, which is the tell. The swap chain is pacing the
|
|
86
|
+
measurement. Raise `renderer.pixel_ratio` until the GPU is the bottleneck (3 gives 3840×2160 and
|
|
87
|
+
~10 ms of real work here), interleave the settings round-robin so drift hits all of them equally,
|
|
88
|
+
and take medians. Remember the froxel grid scales with resolution, so a per-froxel cost measured at
|
|
89
|
+
4× the pixels is 4× the one you will pay.
|
|
90
|
+
|
|
91
|
+
Prefer the **signed mean** when the change has a direction; it discriminates far better than the
|
|
92
|
+
mean-absolute, which is dominated by noise. C-01b read −0.148 at 60° FOV against a floor of ±0.05,
|
|
93
|
+
and −1.071 at 110°: same fix, and that 7× is the `1/cos θ` scaling it predicts.
|
|
94
|
+
|
|
95
|
+
Also: mutating `light.intensity` does not mark the light buffer dirty, so a sweep of it renders
|
|
96
|
+
identically. Remove and re-add the light instead.
|
|
97
|
+
|
|
98
|
+
### measure.js
|
|
99
|
+
|
|
100
|
+
`measure.js` is that protocol as code, loaded from the console rather than by the page:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const m = await (await import('./measure.js')).attach();
|
|
104
|
+
const r = await m.ab(v => globalThis.__VOL_TAA_ENABLED = v, false, true);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`attach` pauses the page's loop — which matters for a reason beyond frame counting: that loop
|
|
108
|
+
assigns `renderer.pixel_ratio = window.devicePixelRatio` on every tick, ahead of its own pause check,
|
|
109
|
+
so a raised pixel ratio is undone before the next frame unless the loop is stopped. `set_pixel_ratio`
|
|
110
|
+
replaces the property for the same reason.
|
|
111
|
+
|
|
112
|
+
Everything renders into a texture the module owns, through `Renderer.render_to_target`, which shares
|
|
113
|
+
the view context and therefore all the temporal history with the canvas path. That is not a
|
|
114
|
+
refinement: a canvas texture is configured by the presentation path, cannot carry `COPY_SRC`, and is
|
|
115
|
+
rotated away the moment the frame is presented, so a `drawImage` off it a task later returns an empty
|
|
116
|
+
bitmap — and a comparison of two blanks reports perfect agreement. Driving the frames from here also
|
|
117
|
+
takes the swap chain out of the timing entirely, which is the presentation-throttling trap above.
|
|
118
|
+
|
|
119
|
+
`ab` runs the two settings interleaved and takes its own floor from a repeat of each. `fly`,
|
|
120
|
+
`converge_at` and `series_stats` are the camera-path half: a structure fitted to the camera can be
|
|
121
|
+
perfectly correct standing still and swim under motion, and neither swimming nor the ghosting the
|
|
122
|
+
0.95-blend TAA turns it into is visible in a still. `time` and `profile` answer different questions and
|
|
123
|
+
`profile` is the one to believe — at 1080p, whole-frame timing of Q-04 came back with the wrong sign
|
|
124
|
+
three runs in a row while the per-pass capture read it cleanly.
|
|
125
|
+
|
|
126
|
+
`profile` records a `WORKLOAD` session and decodes it in the page, so a cost number does not need a
|
|
127
|
+
downloaded `.sgpt` and a second tool. It returns the median duration per pass label over the frames
|
|
128
|
+
it recorded. Interleave the settings and take medians across rounds even so — the run-to-run spread
|
|
129
|
+
on this machine moves a pass by more than most changes to it do:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const p = await m.profile({ frames: 24 });
|
|
133
|
+
p['Volumetrics / build sun shadow volume 0'];
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Recording a GPU profile
|
|
137
|
+
|
|
138
|
+
**T** starts a recording, **T** again stops it and downloads a `.sgpt` capture. The engine side is
|
|
139
|
+
already there — `Renderer.profile_session` is a nullable field and the null checks around it are the
|
|
140
|
+
whole integration — so this page is only the two imports, the key handler, and the metadata a capture
|
|
141
|
+
needs in order to mean anything later: adapter, engine version, device features, and a note carrying
|
|
142
|
+
the viewpoint, FOV, pixel ratio and volumetric TAA setting that were in force when you pressed the
|
|
143
|
+
key.
|
|
144
|
+
|
|
145
|
+
Level is `WORKLOAD`: pass spans, the frame graph, and dispatch/draw counts. Measured on this page it
|
|
146
|
+
runs about 11 KB a frame — roughly 660 KB/s at 60 Hz, and around 393 spans per frame — with the frame
|
|
147
|
+
graph topology stored once and shared by every frame. `VERBOSE` above it adds bind group contents at
|
|
148
|
+
about seven times the rate and wants a frame limit set; `PROFILE_LEVEL` in `main.js` is the one line
|
|
149
|
+
to change.
|
|
150
|
+
|
|
151
|
+
Two things to know. Stopping does not end the recording immediately: a frame is committed when its
|
|
152
|
+
timestamp readback lands, a `mapAsync` a frame or two behind submit, so the handler drains the queue
|
|
153
|
+
and waits for the frame count to settle before it writes the file — otherwise the last frames, the
|
|
154
|
+
ones you were probably looking at, are the ones missing. And the browser asks before letting a page
|
|
155
|
+
save more than one file, so a second capture may never appear; the filename goes to the console
|
|
156
|
+
either way.
|
|
157
|
+
|
|
158
|
+
Frames only accumulate while the page is rendering, so a recording taken while paused stays at zero.
|
|
159
|
+
|
|
160
|
+
## Scene
|
|
161
|
+
|
|
162
|
+
`flying_world_-_battle_of_the_trash_god/v2`, six participating-media volumes, a low sun, and the
|
|
163
|
+
Venice sunset environment. Both the volumes and most of the camera placements are carried over from
|
|
164
|
+
the sibling repo's playground, where they were found by hand against this exact model — keeping the
|
|
165
|
+
numbers means a before/after pair is taken from the same place to the pixel.
|
|
166
|
+
|
|
167
|
+
Assets live under `test_assets/`, which is gitignored; `test_assets/README.md` has how they get
|
|
168
|
+
there. This page needs:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
mkdir -p "test_assets/flying_world_-_battle_of_the_trash_god/v2"
|
|
172
|
+
cp "H:/git/meep-renderer-deferred-algo/public/flying_world_-_battle_of_the_trash_god/v2/scene.gltf" "H:/git/meep-renderer-deferred-algo/public/flying_world_-_battle_of_the_trash_god/v2/scene.bin" "test_assets/flying_world_-_battle_of_the_trash_god/v2/"
|
|
173
|
+
cp "H:/git/meep-renderer-deferred-algo/public/environments/octahedral/venice_sunset_2k.hdr" test_assets/environments/octahedral/
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Only `scene.gltf` and `scene.bin` are needed from that directory — about 26 MB. glTF rather than the
|
|
177
|
+
`.shade` sitting beside it: that export's header reads format version 1 and `deserialize_scene`
|
|
178
|
+
accepts version 2 only, deliberately and with no backward compatibility.
|
|
179
|
+
|
|
180
|
+
## Notes
|
|
181
|
+
|
|
182
|
+
Two viewpoints from the sibling repo (`v_volumetrics_light_extinction_0` and `_1`) sit inside
|
|
183
|
+
geometry here and were replaced with placements verified against this build. Two more —
|
|
184
|
+
*Beside the fog box* and *Beside the fog box, deeper* — were added for Q-04: they are the only
|
|
185
|
+
placements in the scene where the sun's path through the medium is short enough for the 16-step
|
|
186
|
+
march to miss it, which makes them the ones any change to that path has to be judged at. *Log camera* writes the
|
|
187
|
+
current placement to the console in the form the `VIEWS` table takes, which is how to add more.
|
|
188
|
+
|
|
189
|
+
Frame time settles around 28 ms at 1920×1080 on a 4070-class part. That is the whole frame, not the
|
|
190
|
+
volumetrics pass — separating it out needs a timestamp query. It is the baseline the performance
|
|
191
|
+
findings (P-01 … P-06) would move.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>meep — froxel volumetrics</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root {
|
|
8
|
+
color-scheme: dark;
|
|
9
|
+
--panel: rgba(20, 22, 26, 0.88);
|
|
10
|
+
--line: #2b3038;
|
|
11
|
+
--text: #d6dae0;
|
|
12
|
+
--muted: #8b939f;
|
|
13
|
+
--pass: #55c46a;
|
|
14
|
+
--fail: #ff6b6b;
|
|
15
|
+
--warn: #e5b54a;
|
|
16
|
+
--tag: #c98b3f;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
|
|
21
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: #0d0f12; }
|
|
22
|
+
|
|
23
|
+
/* body carries tabindex so the camera controller's keyboard device has something focusable
|
|
24
|
+
to listen on; the focus ring around the whole viewport is not wanted */
|
|
25
|
+
body:focus { outline: none; }
|
|
26
|
+
|
|
27
|
+
#view { display: block; width: 100vw; height: 100vh; }
|
|
28
|
+
|
|
29
|
+
#panel {
|
|
30
|
+
position: fixed;
|
|
31
|
+
top: 12px;
|
|
32
|
+
left: 12px;
|
|
33
|
+
width: 330px;
|
|
34
|
+
max-height: calc(100vh - 24px);
|
|
35
|
+
overflow-y: auto;
|
|
36
|
+
padding: 12px 14px;
|
|
37
|
+
background: var(--panel);
|
|
38
|
+
border: 1px solid var(--line);
|
|
39
|
+
border-radius: 8px;
|
|
40
|
+
backdrop-filter: blur(6px);
|
|
41
|
+
color: var(--text);
|
|
42
|
+
font: 13px/1.5 system-ui, -apple-system, Segoe UI, sans-serif;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#panel h1 { font-size: 13px; font-weight: 600; margin: 0 0 8px; }
|
|
46
|
+
|
|
47
|
+
h2 {
|
|
48
|
+
font-size: 11px;
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
letter-spacing: 0.06em;
|
|
52
|
+
color: var(--muted);
|
|
53
|
+
margin: 0 0 6px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
label { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
|
|
57
|
+
label input[type="checkbox"] { margin: 0; }
|
|
58
|
+
label .hint { color: var(--muted); font-size: 11.5px; }
|
|
59
|
+
|
|
60
|
+
hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; }
|
|
61
|
+
|
|
62
|
+
button {
|
|
63
|
+
font: inherit;
|
|
64
|
+
padding: 5px 11px;
|
|
65
|
+
border-radius: 6px;
|
|
66
|
+
border: 1px solid var(--line);
|
|
67
|
+
background: #1c1f25;
|
|
68
|
+
color: var(--text);
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
button:hover { border-color: #3d4552; }
|
|
73
|
+
button:focus-visible { outline: 2px solid var(--tag); outline-offset: 2px; }
|
|
74
|
+
|
|
75
|
+
#views { display: flex; flex-direction: column; gap: 4px; }
|
|
76
|
+
|
|
77
|
+
button.view {
|
|
78
|
+
text-align: left;
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: baseline;
|
|
81
|
+
gap: 8px;
|
|
82
|
+
padding: 4px 9px;
|
|
83
|
+
font-size: 12.5px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.tag {
|
|
87
|
+
font: 10px ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
88
|
+
color: var(--tag);
|
|
89
|
+
border: 1px solid currentColor;
|
|
90
|
+
border-radius: 3px;
|
|
91
|
+
padding: 0 4px;
|
|
92
|
+
flex: 0 0 auto;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.slider {
|
|
96
|
+
display: grid;
|
|
97
|
+
grid-template-columns: 1fr auto;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 2px 8px;
|
|
100
|
+
padding: 4px 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.slider .name { font-size: 12px; }
|
|
104
|
+
.slider .value {
|
|
105
|
+
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
text-align: right;
|
|
108
|
+
min-width: 44px;
|
|
109
|
+
}
|
|
110
|
+
.slider input[type="range"] { grid-column: 1 / -1; width: 100%; margin: 0; }
|
|
111
|
+
|
|
112
|
+
.row { display: flex; gap: 6px; align-items: center; }
|
|
113
|
+
.row button { flex: 1 1 auto; }
|
|
114
|
+
|
|
115
|
+
#status { font-size: 12px; color: var(--muted); word-break: break-word; margin-top: 6px; }
|
|
116
|
+
#status.pass { color: var(--pass); }
|
|
117
|
+
#status.fail { color: var(--fail); }
|
|
118
|
+
#status.warn { color: var(--warn); }
|
|
119
|
+
|
|
120
|
+
#stats, #profile_status {
|
|
121
|
+
margin-top: 4px;
|
|
122
|
+
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
123
|
+
color: var(--text);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#profile_status.recording { color: var(--fail); }
|
|
127
|
+
|
|
128
|
+
p.note { margin: 6px 0 0; font-size: 11.5px; color: var(--muted); }
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body tabindex="0">
|
|
132
|
+
|
|
133
|
+
<canvas id="view"></canvas>
|
|
134
|
+
|
|
135
|
+
<div id="panel">
|
|
136
|
+
<h1>Froxel volumetrics</h1>
|
|
137
|
+
|
|
138
|
+
<h2>Viewpoints</h2>
|
|
139
|
+
<div id="views"></div>
|
|
140
|
+
|
|
141
|
+
<hr>
|
|
142
|
+
|
|
143
|
+
<h2>Sun</h2>
|
|
144
|
+
|
|
145
|
+
<div class="row">
|
|
146
|
+
<button id="sun_into_view">Sun into view</button>
|
|
147
|
+
<button id="sun_behind">Sun behind</button>
|
|
148
|
+
</div>
|
|
149
|
+
<p class="note">
|
|
150
|
+
Places the sun on the camera's forward axis, or directly opposite it. Media self-shadowing
|
|
151
|
+
depends on the shadow ray's path through the grid, so swinging the sun through the frustum
|
|
152
|
+
is the quickest way to exercise it.
|
|
153
|
+
</p>
|
|
154
|
+
|
|
155
|
+
<div class="slider">
|
|
156
|
+
<span class="name">Azimuth</span>
|
|
157
|
+
<span class="value" id="sun_azimuth_value">200°</span>
|
|
158
|
+
<input type="range" id="sun_azimuth" min="0" max="359" step="1" value="200">
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div class="slider">
|
|
162
|
+
<span class="name">Elevation</span>
|
|
163
|
+
<span class="value" id="sun_elevation_value">12°</span>
|
|
164
|
+
<input type="range" id="sun_elevation" min="1" max="80" step="1" value="12">
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<hr>
|
|
168
|
+
|
|
169
|
+
<h2>Camera</h2>
|
|
170
|
+
|
|
171
|
+
<div class="slider">
|
|
172
|
+
<span class="name">Field of view</span>
|
|
173
|
+
<span class="value" id="fov_value">60°</span>
|
|
174
|
+
<input type="range" id="fov" min="30" max="110" step="1" value="60">
|
|
175
|
+
</div>
|
|
176
|
+
<p class="note">
|
|
177
|
+
Froxel geometry is angle-dependent — the planar depth curve and the ray it is walked along
|
|
178
|
+
diverge as cos θ. A wide FOV is where anything that confuses the two shows up, so open
|
|
179
|
+
this to 110° when judging a change to the grid.
|
|
180
|
+
</p>
|
|
181
|
+
|
|
182
|
+
<hr>
|
|
183
|
+
|
|
184
|
+
<h2>Volumetrics</h2>
|
|
185
|
+
|
|
186
|
+
<label>
|
|
187
|
+
<input type="checkbox" id="vol_taa" checked>
|
|
188
|
+
<span>Volumetric TAA <span class="hint">— off exposes the raw per-frame noise</span></span>
|
|
189
|
+
</label>
|
|
190
|
+
|
|
191
|
+
<label>
|
|
192
|
+
<input type="checkbox" id="taa" checked>
|
|
193
|
+
<span>Renderer TAA</span>
|
|
194
|
+
</label>
|
|
195
|
+
|
|
196
|
+
<label>
|
|
197
|
+
<input type="checkbox" id="auto_exposure">
|
|
198
|
+
<span>Auto exposure <span class="hint">— off: A/B is meaningless with it on</span></span>
|
|
199
|
+
</label>
|
|
200
|
+
<p class="note">
|
|
201
|
+
Eye adaptation drifts for seconds after any change, so two captures of the same setting can
|
|
202
|
+
differ by more than the change being judged. It starts off here for that reason; turn it on
|
|
203
|
+
only to see how something reads in a normally-exposed frame.
|
|
204
|
+
</p>
|
|
205
|
+
|
|
206
|
+
<div class="slider">
|
|
207
|
+
<span class="name">Froxel far</span>
|
|
208
|
+
<span class="value" id="froxel_far_value">off</span>
|
|
209
|
+
<input type="range" id="froxel_far" min="20" max="120" step="5" value="120">
|
|
210
|
+
</div>
|
|
211
|
+
<p class="note">
|
|
212
|
+
Packs the froxel grid into a shorter range than <code>camera.far</code>, so the near field
|
|
213
|
+
gets more slices and the aerial pass ray-marches the volumes beyond it. At <em>off</em> the
|
|
214
|
+
grid spans the whole range and the two depth curves coincide.
|
|
215
|
+
</p>
|
|
216
|
+
|
|
217
|
+
<div class="slider">
|
|
218
|
+
<span class="name">Multiscatter octaves</span>
|
|
219
|
+
<span class="value" id="ms_octaves_value">6</span>
|
|
220
|
+
<input type="range" id="ms_octaves" min="1" max="8" step="1" value="6">
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<hr>
|
|
224
|
+
|
|
225
|
+
<h2>Profiling</h2>
|
|
226
|
+
|
|
227
|
+
<div id="profile_status">idle</div>
|
|
228
|
+
<p class="note">
|
|
229
|
+
<strong>T</strong> starts a GPU profile recording; <strong>T</strong> again stops it and
|
|
230
|
+
downloads the <code>.sgpt</code> capture. Level is <code>WORKLOAD</code> — pass spans, the
|
|
231
|
+
frame graph, and dispatch/draw counts, which measures around 11 KB a frame on this page.
|
|
232
|
+
Frames only accumulate while the page is rendering, so a recording taken while paused stays
|
|
233
|
+
at zero.
|
|
234
|
+
</p>
|
|
235
|
+
<p class="note">
|
|
236
|
+
The browser asks before letting a page save more than one file. If a second capture never
|
|
237
|
+
appears, that prompt is why — allow it, and the filename is on the console either way.
|
|
238
|
+
</p>
|
|
239
|
+
|
|
240
|
+
<hr>
|
|
241
|
+
|
|
242
|
+
<div class="row">
|
|
243
|
+
<button id="pause">Pause</button>
|
|
244
|
+
<button id="log_camera">Log camera</button>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div id="status"></div>
|
|
248
|
+
<div id="stats"></div>
|
|
249
|
+
|
|
250
|
+
<p class="note">
|
|
251
|
+
Drag to orbit, wheel to zoom, WASD to move. <em>Log camera</em> writes the current placement
|
|
252
|
+
to the console in the form the <code>VIEWS</code> table takes.
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<script type="module">
|
|
257
|
+
import { start } from './main.js';
|
|
258
|
+
|
|
259
|
+
start().catch(error => {
|
|
260
|
+
const status = document.getElementById('status');
|
|
261
|
+
|
|
262
|
+
status.className = 'fail';
|
|
263
|
+
status.textContent = String(error?.message ?? error);
|
|
264
|
+
|
|
265
|
+
console.error(error);
|
|
266
|
+
});
|
|
267
|
+
</script>
|
|
268
|
+
|
|
269
|
+
</body>
|
|
270
|
+
</html>
|