three-realtime-rt 0.6.1 → 0.7.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.
@@ -16,6 +16,39 @@ const MAX_LIGHTS = 32; // stage-1 cap; a data-texture light list is future work
16
16
  // from the light list with a warning.
17
17
  const MAX_EMISSIVE_TRIS = 256;
18
18
 
19
+ // ---- usage diagnostics (warn-once) -----------------------------------------
20
+ // Silent "it looks wrong but nothing errored" mistakes are the expensive kind:
21
+ // a flag that is ignored, an object type that never reaches the BVH, a mesh
22
+ // edited after compileScene(). Each one below is detected at compile time and
23
+ // reported ONCE per object (never per frame, never per recompile) with the exact
24
+ // fix. The message text is also pushed onto the CompiledScene as
25
+ // `warnings: [{ code, message }]` so RealtimeRaytracer can mirror it into
26
+ // `status.warnings` for a UI / an automated check.
27
+ //
28
+ // Warn-once state is keyed by the OBJECT (a WeakMap of code sets), so recompiling
29
+ // the same scene does not re-spam the console; the returned `warnings` array is
30
+ // still rebuilt on every compile so the status surface stays complete.
31
+ const _warnedObjects = new WeakMap();
32
+ function _firstTime(obj, code) {
33
+ let set = _warnedObjects.get(obj);
34
+ if (!set) { set = new Set(); _warnedObjects.set(obj, set); }
35
+ if (set.has(code)) return false;
36
+ set.add(code);
37
+ return true;
38
+ }
39
+
40
+ // Human-readable identifier for a scene object in a diagnostic message.
41
+ function describeObject(obj) {
42
+ if (!obj) return "(null)";
43
+ return obj.name ? `"${obj.name}"` : `(unnamed ${obj.type || "Object3D"})`;
44
+ }
45
+
46
+ // Join up to `max` object names for a message that covers a whole category.
47
+ function describeList(objs, max = 4) {
48
+ const head = objs.slice(0, max).map(describeObject).join(", ");
49
+ return objs.length > max ? `${head} and ${objs.length - max} more` : head;
50
+ }
51
+
19
52
  /**
20
53
  * A two-level BVH scene. Static geometry lives in one BVH uploaded to the GPU
21
54
  * ONCE; dynamic (moving) meshes live in a second, small BVH that is re-baked and
@@ -49,8 +82,24 @@ export class CompiledScene {
49
82
  // (shape changes each frame, so it forces a per-frame normal upload too).
50
83
  this.hasSkinned = false;
51
84
 
85
+ // Usage diagnostics raised while compiling THIS scene: [{ code, message }].
86
+ // Console output happens once per offending object at collection time;
87
+ // RealtimeRaytracer mirrors this array into status.warnings.
88
+ this.warnings = [];
89
+ // Per-static-mesh fingerprints ({ ref: WeakRef, name, version, matrix,
90
+ // warned }) used to detect a static mesh edited after compile time. Only the
91
+ // WeakRef points at the app's mesh, so this never keeps a scene alive.
92
+ this.staticSources = [];
93
+
52
94
  this.materialsTex = null;
53
95
  this.materials = [];
96
+ // World-space 3D-texture albedo (see collectVolumeAlbedo). null when no
97
+ // material opted in via userData.rtVolumeAlbedo. v1 is single-volume: the
98
+ // FIRST opted-in material wins for the traced-bounce path (the lighting
99
+ // megakernel takes one sampler3D + one material index). Primary visibility
100
+ // (the G-buffer) can carry any number of distinct volumes; only the traced
101
+ // secondary rays are limited to one in v1.
102
+ this.volumeAlbedo = null;
54
103
  this.lightPosType = [];
55
104
  this.lightColorRadius = [];
56
105
  this.lightDirCone = []; // spot direction.xyz + cos(outer angle)
@@ -328,6 +377,8 @@ export class CompiledScene {
328
377
  if (this.materialsTex) this.materialsTex.dispose();
329
378
  if (this.staticBvh) this.staticBvh.geometry.dispose();
330
379
  if (this.dynamicMerged) this.dynamicMerged.dispose();
380
+ // Drop the staleness fingerprints (WeakRefs + a 16-float matrix each).
381
+ this.staticSources = [];
331
382
  }
332
383
  }
333
384
 
@@ -488,6 +539,65 @@ function emissiveColor(mat) {
488
539
  return [mat.emissive.r * i, mat.emissive.g * i, mat.emissive.b * i];
489
540
  }
490
541
 
542
+ // World-space 3D-texture albedo ("volumetric surface albedo"). A material opts
543
+ // in with `material.userData.rtVolumeAlbedo = { texture, origin, size }`:
544
+ // texture — a THREE.Data3DTexture, ALREADY colour-mapped to RGB(A); the tracer
545
+ // samples its .rgb at the hit point (no colormap logic in the library)
546
+ // origin — THREE.Vector3, world position of the texel-(0,0,0) corner
547
+ // size — THREE.Vector3, world extent of the full volume along each axis
548
+ // The hit point p maps to uvw = clamp((p - origin) / size, 0, 1) and the trilinear
549
+ // sample replaces the material's base albedo. Everything else (roughness, metalness,
550
+ // emissive) composes normally. Returns { matIndex, texture, origin, size, material }
551
+ // for the FIRST opted-in material (v1 single-volume for the traced-bounce path), or
552
+ // null. `materials` is the deduped table, so matIndex is the exact index the BVH
553
+ // per-vertex attribute stores and the lighting pass reads via fetchMaterial().
554
+ function collectVolumeAlbedo(materials) {
555
+ let found = null;
556
+ let extra = 0;
557
+ for (let i = 0; i < materials.length; i++) {
558
+ const desc = materials[i] && materials[i].userData && materials[i].userData.rtVolumeAlbedo;
559
+ if (!desc) continue;
560
+ if (found) { extra++; continue; }
561
+ const texture = desc.texture;
562
+ const is3D = texture && (texture.isData3DTexture || texture.isDataArrayTexture ||
563
+ (texture.image && texture.image.depth > 0));
564
+ if (!texture || !is3D) {
565
+ console.warn(
566
+ "three-realtime-rt: userData.rtVolumeAlbedo.texture must be a THREE.Data3DTexture " +
567
+ "(RGB[A], pre-colormapped) — ignoring this material's volume albedo."
568
+ );
569
+ continue;
570
+ }
571
+ const origin = new THREE.Vector3().copy(desc.origin ?? new THREE.Vector3(0, 0, 0));
572
+ // Guard a zero extent (would divide by zero in the shader) — fall back to 1.
573
+ const size = new THREE.Vector3().copy(desc.size ?? new THREE.Vector3(1, 1, 1));
574
+ if (size.x === 0) size.x = 1;
575
+ if (size.y === 0) size.y = 1;
576
+ if (size.z === 0) size.z = 1;
577
+ // Trilinear + clamp-to-edge is what "sample the field on the surface" means;
578
+ // set it here so callers don't have to remember (the shader also clamps uvw,
579
+ // so wrapping only matters for the edge texel's interpolation). We only touch
580
+ // filtering/wrapping, never the caller's data or colour space.
581
+ let changed = false;
582
+ if (texture.magFilter !== THREE.LinearFilter) { texture.magFilter = THREE.LinearFilter; changed = true; }
583
+ if (texture.minFilter !== THREE.LinearFilter) { texture.minFilter = THREE.LinearFilter; changed = true; }
584
+ if (texture.wrapS !== THREE.ClampToEdgeWrapping) { texture.wrapS = THREE.ClampToEdgeWrapping; changed = true; }
585
+ if (texture.wrapT !== THREE.ClampToEdgeWrapping) { texture.wrapT = THREE.ClampToEdgeWrapping; changed = true; }
586
+ if (texture.wrapR !== THREE.ClampToEdgeWrapping) { texture.wrapR = THREE.ClampToEdgeWrapping; changed = true; }
587
+ if (changed) texture.needsUpdate = true;
588
+ found = { matIndex: i, texture, origin, size, material: materials[i] };
589
+ }
590
+ if (extra > 0) {
591
+ console.warn(
592
+ `three-realtime-rt: ${extra + 1} materials set userData.rtVolumeAlbedo, but v1 samples ` +
593
+ `only ONE volume in the traced-bounce (GI/reflection) path — keeping the first. The other ` +
594
+ `volumes still render correctly in primary visibility (the G-buffer); multi-volume bounces ` +
595
+ `are future work.`
596
+ );
597
+ }
598
+ return found;
599
+ }
600
+
491
601
  // Row 0: materials, 2 texels each (albedo+rough, emissive+metal).
492
602
  // Row 1: emissive triangles for NEE, 4 texels each:
493
603
  // [v0.xyz | area] [e1.xyz | emit.r] [e2.xyz | emit.g] [n.xyz | emit.b]
@@ -650,6 +760,22 @@ export function compileScene(scene, options = {}) {
650
760
  let dynVertexOffset = 0;
651
761
  const tmpGeoms = []; // to dispose after merge
652
762
 
763
+ // Usage diagnostics collected during the traverse (see _warnedObjects above).
764
+ // Each category is reported as ONE aggregated message after the traverse, so a
765
+ // scene with fifty offenders costs one console line, not fifty.
766
+ const warnings = [];
767
+ const diag = {
768
+ "rtdeforming-not-dynamic": [],
769
+ "untraceable-object": [],
770
+ "instanced-mesh": [],
771
+ "transparent-dynamic": [],
772
+ };
773
+ // Per-static-mesh fingerprints for the post-compile staleness check (see
774
+ // RealtimeRaytracer._checkStale). WeakRef so a mesh removed from the scene
775
+ // can still be collected — the compiled scene must never keep it alive.
776
+ const staticSources = [];
777
+ const canTrack = typeof WeakRef === "function";
778
+
653
779
  const registerMaterial = (m) => {
654
780
  let i = materials.indexOf(m);
655
781
  if (i < 0) { i = materials.length; materials.push(m); }
@@ -657,7 +783,19 @@ export function compileScene(scene, options = {}) {
657
783
  };
658
784
 
659
785
  scene.traverse((obj) => {
786
+ // Object types the tracer cannot represent: they are not triangles in a BVH,
787
+ // and their ESSL1 materials cannot write the 4-attachment G-buffer either, so
788
+ // GBufferPass hides them for the traced frame. Say so once. An explicit
789
+ // rtExclude means the app already knows — stay quiet for those.
790
+ if ((obj.isSprite || obj.isLine || obj.isPoints) && obj.visible && !obj.userData.rtExclude) {
791
+ diag["untraceable-object"].push(obj);
792
+ return;
793
+ }
660
794
  if (!obj.isMesh || !obj.geometry || !obj.visible || obj.userData.rtExclude) return;
795
+ // An InstancedMesh is traversed as ONE mesh: its per-instance matrices are a
796
+ // GPU attribute the compiler never reads, so every instance past the first
797
+ // silently vanishes from the traced world (and from the G-buffer).
798
+ if (obj.isInstancedMesh) diag["instanced-mesh"].push(obj);
661
799
  const isArray = Array.isArray(obj.material);
662
800
  const rep = isArray ? obj.material[0] : obj.material;
663
801
  // Transparent surfaces must not act as opaque occluders — e.g.
@@ -665,9 +803,22 @@ export function compileScene(scene, options = {}) {
665
803
  // the whole model in shadow. Alpha-textured glass can't be cheaply tested,
666
804
  // so ANY transparent material is skipped like rtExclude (still
667
805
  // rasterized). alphaTest cut-outs (transparent: false) stay occluders.
668
- if (rep.transparent) return;
806
+ if (rep.transparent) {
807
+ // Listing a transparent mesh in dynamicMeshes does nothing at all — it is
808
+ // dropped here, BEFORE the dynamic registration below, so it is never
809
+ // BVH-traced and updateDynamic() never touches it.
810
+ if (dynamicSet && dynamicSet.has(obj)) diag["transparent-dynamic"].push(obj);
811
+ return;
812
+ }
669
813
 
670
814
  const isDynamic = dynamicSet && dynamicSet.has(obj);
815
+ // rtDeforming alone does nothing: the flag is only read for meshes that are
816
+ // ALSO in dynamicMeshes (there is no dynamic segment to re-bake otherwise).
817
+ // Without this warning the mesh compiles static and its traced shadow keeps
818
+ // the compile-time shape forever, silently.
819
+ if (obj.userData.rtDeforming === true && !isDynamic) {
820
+ diag["rtdeforming-not-dynamic"].push(obj);
821
+ }
671
822
  // Opt-in CPU deformation: the mesh must be BOTH in dynamicMeshes AND carry
672
823
  // userData.rtDeforming, and its live geometry is read every frame.
673
824
  const deforming = isDynamic && obj.userData.rtDeforming === true;
@@ -736,9 +887,65 @@ export function compileScene(scene, options = {}) {
736
887
  const emit = emissiveColor(r.material);
737
888
  if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris, r.start, r.vcount);
738
889
  }
890
+ // Fingerprint this static source so a later edit (vertices moved, mesh
891
+ // moved) can be DETECTED instead of quietly tracing the old shape. Two
892
+ // cheap comparands: the position attribute's version counter and a copy of
893
+ // matrixWorld. Held via WeakRef — never a strong reference.
894
+ if (canTrack) {
895
+ const pos = obj.geometry.getAttribute("position");
896
+ staticSources.push({
897
+ ref: new WeakRef(obj),
898
+ name: describeObject(obj),
899
+ version: pos ? pos.version : -1,
900
+ // Float64Array, NOT Float32Array: three's Matrix4.elements holds
901
+ // doubles, so a float32 snapshot rounds every element by up to ~1e-7
902
+ // and EVERY static mesh then compares as "moved" (measured: the demo
903
+ // scene reported 36 of 41 sources stale on the first run of this scan).
904
+ matrix: new Float64Array(obj.matrixWorld.elements),
905
+ warned: false,
906
+ });
907
+ }
739
908
  }
740
909
  });
741
910
 
911
+ // ---- report the collected usage diagnostics (one line per category) -------
912
+ const note = (code, message) => {
913
+ warnings.push({ code, message });
914
+ };
915
+ const report = (code, objs, build) => {
916
+ if (objs.length === 0) return;
917
+ const fresh = objs.filter((o) => _firstTime(o, code));
918
+ const message = build(objs);
919
+ note(code, message);
920
+ if (fresh.length > 0) console.warn(message);
921
+ };
922
+ report("rtdeforming-not-dynamic", diag["rtdeforming-not-dynamic"], (objs) =>
923
+ `three-realtime-rt: userData.rtDeforming is set on a mesh that is NOT in ` +
924
+ `compileScene(scene, {dynamicMeshes:[...]}) — the flag is IGNORED, the mesh ` +
925
+ `compiles STATIC, and traced shadows/GI keep its compile-time shape forever: ` +
926
+ `${describeList(objs)}. Add it to dynamicMeshes and call updateDynamic() each ` +
927
+ `frame to make it actually deform.`
928
+ );
929
+ report("untraceable-object", diag["untraceable-object"], (objs) =>
930
+ `three-realtime-rt: Sprite/Line/Points objects are not traceable geometry and ` +
931
+ `are auto-hidden from the traced frame (their materials cannot write the ` +
932
+ `4-attachment G-buffer): ${describeList(objs)}. Draw them in your own overlay ` +
933
+ `pass on top of rt.render(), or set userData.rtExclude = true to silence this.`
934
+ );
935
+ report("instanced-mesh", diag["instanced-mesh"], (objs) =>
936
+ `three-realtime-rt: InstancedMesh is NOT supported — it collapses to a single ` +
937
+ `instance in the traced output and in the G-buffer: ${describeList(objs)}. ` +
938
+ `Expand it to individual meshes, or set userData.rtExclude = true to exclude it.`
939
+ );
940
+ report("transparent-dynamic", diag["transparent-dynamic"], (objs) =>
941
+ `three-realtime-rt: a transparent mesh listed in dynamicMeshes does nothing — ` +
942
+ `transparent meshes are composited via the blend path and are never BVH-traced ` +
943
+ `or dynamic-registered: ${describeList(objs)}. Remove it from dynamicMeshes, or ` +
944
+ `make the material opaque (transparent: false) if it should cast traced shadows.`
945
+ );
946
+ compiled.warnings = warnings;
947
+ compiled.staticSources = staticSources;
948
+
742
949
  if (staticGeoms.length === 0 && dynamicGeoms.length === 0) {
743
950
  throw new Error("three-realtime-rt: no meshes found in scene");
744
951
  }
@@ -796,6 +1003,10 @@ export function compileScene(scene, options = {}) {
796
1003
  }
797
1004
  compiled.hasDynamicEmissive = compiled._dynamicEmissive.length > 0;
798
1005
  compiled.materialsTex = buildSceneDataTexture(materials, emissiveTris);
1006
+ // World-space 3D-texture albedo opt-in (userData.rtVolumeAlbedo). Resolved from
1007
+ // the deduped material table so the recorded matIndex matches what the BVH
1008
+ // per-vertex attribute stores and the lighting pass reads.
1009
+ compiled.volumeAlbedo = collectVolumeAlbedo(materials);
799
1010
  syncLights(scene, compiled);
800
1011
 
801
1012
  // Static merged geometry is owned by its BVH (disposed with it); dynamic
package/src/index.d.ts CHANGED
@@ -5,11 +5,56 @@ import type {
5
5
  Color,
6
6
  Vector3,
7
7
  Object3D,
8
+ Data3DTexture,
8
9
  } from "three";
9
10
 
10
11
  /** Capability tier used to pick sensible defaults. */
11
12
  export type Tier = "none" | "mid" | "high";
12
13
 
14
+ /**
15
+ * World-space 3D-texture albedo ("volumetric surface albedo"). Set it on a
16
+ * material's `userData.rtVolumeAlbedo` to make the tracer sample a 3D texture at
17
+ * the world-space HIT POINT for that surface's albedo — colouring a mesh by a
18
+ * volumetric data field (stress, temperature, density) instead of a flat colour
19
+ * or a 2D UV map. The colour replaces the base albedo in BOTH primary visibility
20
+ * (the G-buffer, so the raster/hybrid view agrees) and the traced GI / reflection
21
+ * bounces (so the field's colours bleed correctly through global illumination).
22
+ * Roughness, metalness and emissive still compose normally.
23
+ *
24
+ * ```js
25
+ * material.userData.rtVolumeAlbedo = {
26
+ * texture, // THREE.Data3DTexture, RGB(A), already colour-mapped
27
+ * origin: new THREE.Vector3(), // world position of the texel-(0,0,0) corner
28
+ * size: new THREE.Vector3(1,1,1), // world extent of the full volume
29
+ * };
30
+ * rt.compileScene(scene); // (re)compile after adding/changing it
31
+ * ```
32
+ *
33
+ * The hit point `p` maps to `uvw = clamp((p - origin) / size, 0, 1)` and is
34
+ * sampled trilinearly (the library sets Linear filtering + ClampToEdge on the
35
+ * texture at compile time). The texture must be **already colour-mapped to RGB** —
36
+ * the library samples `.rgb` directly and contains no colormap logic. Changing the
37
+ * texture DATA later needs only `texture.needsUpdate = true` (no recompile);
38
+ * changing which material carries the field, or its `origin` / `size`, needs a
39
+ * `compileScene()`.
40
+ *
41
+ * **v1 is single-volume for the traced-bounce path.** Any number of materials may
42
+ * carry distinct volumes and each renders correctly in primary visibility, but the
43
+ * GI / reflection bounce samples only the FIRST registered volume (the lighting
44
+ * megakernel is at the WebGL2 16-sampler minimum; the feature adds one sampler3D,
45
+ * enabled only when the GPU exposes ≥ 17 fragment texture units — on a bare-minimum
46
+ * 16-unit device the bounces fall back to the material's flat base colour while
47
+ * primary visibility still shows the full field). Multi-volume bounces are future work.
48
+ */
49
+ export interface VolumeAlbedo {
50
+ /** A THREE.Data3DTexture, RGB(A) and already colour-mapped; `.rgb` is sampled. */
51
+ texture: Data3DTexture;
52
+ /** World position of the texel-(0,0,0) corner of the volume. */
53
+ origin: Vector3;
54
+ /** World extent of the full texture volume along each axis (non-zero). */
55
+ size: Vector3;
56
+ }
57
+
13
58
  /** Result of {@link RealtimeRaytracer.probeGPUTier}. */
14
59
  export interface GPUTierProbe {
15
60
  /** Chosen capability tier. */
@@ -299,6 +344,36 @@ export interface DisabledPass {
299
344
  * with a reason instead of guessing. Populated over the first several rendered
300
345
  * frames (link status is checked lazily / can be deferred by the driver).
301
346
  */
347
+ /**
348
+ * A **usage** diagnostic on {@link RaytracerStatus.warnings} (since 0.7.0). Unlike
349
+ * {@link DisabledPass}, nothing is broken in the pipeline — the SCENE SETUP is
350
+ * not what the app probably intended (a flag that is being ignored, an object
351
+ * type that cannot be traced, a static mesh edited after `compileScene()`). Each
352
+ * one is also `console.warn`ed once, with the exact fix in the text.
353
+ */
354
+ export interface RaytracerWarning {
355
+ /**
356
+ * Stable machine-readable identifier:
357
+ * - `"stale-geometry"` — a static mesh's `position` buffer changed after `compileScene()`.
358
+ * - `"stale-transform"` — a static mesh was moved after `compileScene()`.
359
+ * - `"rtdeforming-not-dynamic"` — `userData.rtDeforming` set on a mesh that is not in `dynamicMeshes` (ignored).
360
+ * - `"implicit-compile"` — `render()` compiled the scene itself, so it was compiled with no options.
361
+ * - `"untraceable-object"` — a visible `Sprite` / `Line` / `Points` was auto-hidden from the traced frame.
362
+ * - `"instanced-mesh"` — an `InstancedMesh` collapses to a single instance.
363
+ * - `"transparent-dynamic"` — a transparent mesh listed in `dynamicMeshes` (the entry does nothing).
364
+ */
365
+ code:
366
+ | "stale-geometry"
367
+ | "stale-transform"
368
+ | "rtdeforming-not-dynamic"
369
+ | "implicit-compile"
370
+ | "untraceable-object"
371
+ | "instanced-mesh"
372
+ | "transparent-dynamic";
373
+ /** Full human-readable message (names the object, the consequence and the fix). */
374
+ message: string;
375
+ }
376
+
302
377
  export interface RaytracerStatus {
303
378
  /**
304
379
  * True while every ray tracing pass is running as intended. Flips to false the
@@ -314,6 +389,13 @@ export interface RaytracerStatus {
314
389
  * is a `"rt:<pass>: <driver log>"` summary; null when no core pass has failed.
315
390
  */
316
391
  coreFailure: string | null;
392
+ /**
393
+ * Usage diagnostics raised for this scene (**since 0.7.0**) — see
394
+ * {@link RaytracerWarning}. Deduplicated by `code` + `message`, and each one is
395
+ * also `console.warn`ed once. **`ok` is NOT affected by warnings**: the
396
+ * pipeline is compiling and running fine, the scene setup is what looks wrong.
397
+ */
398
+ warnings: RaytracerWarning[];
317
399
  }
318
400
 
319
401
  /** Options accepted by {@link RealtimeRaytracer.compileScene} and {@link compileScene}. */
@@ -359,8 +441,24 @@ export class CompiledScene {
359
441
  sceneDiagonal: number;
360
442
  /** True when any dynamic emitter contributes NEE rows refreshed each frame. */
361
443
  hasDynamicEmissive: boolean;
444
+ /**
445
+ * The resolved world-space 3D-texture albedo for the traced-bounce path (see
446
+ * {@link VolumeAlbedo}), or `null` when no material opted in via
447
+ * `userData.rtVolumeAlbedo`. `matIndex` is the material's index in the compiled
448
+ * table (v1 keeps the first opted-in material for the GI/reflection path).
449
+ */
450
+ volumeAlbedo:
451
+ | { matIndex: number; texture: Data3DTexture; origin: Vector3; size: Vector3; material: unknown }
452
+ | null;
362
453
  /** CPU cost (ms) of the most recent dynamic-emissive refresh (0 if none). */
363
454
  lastEmissiveRefreshMs: number;
455
+ /**
456
+ * Usage diagnostics raised while compiling this scene (**since 0.7.0**). The
457
+ * standalone {@link compileScene} reports these to the console; going through
458
+ * {@link RealtimeRaytracer.compileScene} additionally mirrors them onto
459
+ * {@link RaytracerStatus.warnings}.
460
+ */
461
+ warnings: RaytracerWarning[];
364
462
  /**
365
463
  * Re-bake moving meshes' current world transforms and refit the dynamic BVH.
366
464
  * Also refreshes any dynamic emitters' NEE area-light rows + power CDF from the
@@ -422,7 +520,8 @@ export class RealtimeRaytracer {
422
520
  * link; `status.disabled` lists auto-disabled optional features; and
423
521
  * `status.coreFailure` names an unrecoverable core-pass failure. Populated over
424
522
  * the first several rendered frames. When `supported` is false, `status.ok` is
425
- * false too (the RT pipeline is not operational).
523
+ * false too (the RT pipeline is not operational). `status.warnings` (since
524
+ * 0.7.0) carries USAGE diagnostics and never changes `status.ok`.
426
525
  */
427
526
  status: RaytracerStatus;
428
527
  /** Accumulated frame counter. */