@zephyr3d/scene 0.9.26 → 0.9.27

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 (95) hide show
  1. package/dist/animation/animationset.js +92 -30
  2. package/dist/animation/animationset.js.map +1 -1
  3. package/dist/animation/joint_dynamics/convex_collider.js +320 -0
  4. package/dist/animation/joint_dynamics/convex_collider.js.map +1 -0
  5. package/dist/animation/skeleton.js +21 -1
  6. package/dist/animation/skeleton.js.map +1 -1
  7. package/dist/asset/model.js +6 -2
  8. package/dist/asset/model.js.map +1 -1
  9. package/dist/camera/camera.js +75 -23
  10. package/dist/camera/camera.js.map +1 -1
  11. package/dist/index.d.ts +4764 -3600
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/material/hairstrand.js +235 -235
  15. package/dist/material/hairstrand.js.map +1 -1
  16. package/dist/material/material.js +296 -296
  17. package/dist/material/material.js.map +1 -1
  18. package/dist/material/mixins/lit.js +33 -1
  19. package/dist/material/mixins/lit.js.map +1 -1
  20. package/dist/material/shader/helper.js +404 -3
  21. package/dist/material/shader/helper.js.map +1 -1
  22. package/dist/material/water.js +2057 -72
  23. package/dist/material/water.js.map +1 -1
  24. package/dist/posteffect/motionblur.js +663 -58
  25. package/dist/posteffect/motionblur.js.map +1 -1
  26. package/dist/posteffect/posteffect.js +12 -1
  27. package/dist/posteffect/posteffect.js.map +1 -1
  28. package/dist/posteffect/sao.js +46 -44
  29. package/dist/posteffect/sao.js.map +1 -1
  30. package/dist/posteffect/sss.js +30 -2
  31. package/dist/posteffect/sss.js.map +1 -1
  32. package/dist/render/abuffer_oit.js +37 -37
  33. package/dist/render/abuffer_oit.js.map +1 -1
  34. package/dist/render/clipmap.js +156 -5
  35. package/dist/render/clipmap.js.map +1 -1
  36. package/dist/render/cull_visitor.js +1 -0
  37. package/dist/render/cull_visitor.js.map +1 -1
  38. package/dist/render/dualdepthpeeling_oit.js +49 -49
  39. package/dist/render/dualdepthpeeling_oit.js.map +1 -1
  40. package/dist/render/fbm_wavegenerator.js +3 -3
  41. package/dist/render/fbm_wavegenerator.js.map +1 -1
  42. package/dist/render/fft_wavegenerator.js +57 -15
  43. package/dist/render/fft_wavegenerator.js.map +1 -1
  44. package/dist/render/gerstner_wavegenerator.js +127 -20
  45. package/dist/render/gerstner_wavegenerator.js.map +1 -1
  46. package/dist/render/hzb.js +80 -22
  47. package/dist/render/hzb.js.map +1 -1
  48. package/dist/render/lightpass.js +6 -3
  49. package/dist/render/lightpass.js.map +1 -1
  50. package/dist/render/oit.js +11 -11
  51. package/dist/render/oit.js.map +1 -1
  52. package/dist/render/render_queue.js +36 -0
  53. package/dist/render/render_queue.js.map +1 -1
  54. package/dist/render/renderer.js +4 -0
  55. package/dist/render/renderer.js.map +1 -1
  56. package/dist/render/rendergraph/blackboard.js +1 -0
  57. package/dist/render/rendergraph/blackboard.js.map +1 -1
  58. package/dist/render/rendergraph/forward_plus_builder.js +318 -9
  59. package/dist/render/rendergraph/forward_plus_builder.js.map +1 -1
  60. package/dist/render/rendergraph/frame_resource_requirements.js +1 -0
  61. package/dist/render/rendergraph/frame_resource_requirements.js.map +1 -1
  62. package/dist/render/rendergraph/history_resources.js +8 -1
  63. package/dist/render/rendergraph/history_resources.js.map +1 -1
  64. package/dist/render/underwater.js +624 -0
  65. package/dist/render/underwater.js.map +1 -0
  66. package/dist/render/water_caustics.js +1380 -0
  67. package/dist/render/water_caustics.js.map +1 -0
  68. package/dist/render/weightedblended_oit.js +33 -33
  69. package/dist/render/weightedblended_oit.js.map +1 -1
  70. package/dist/scene/water.js +540 -4
  71. package/dist/scene/water.js.map +1 -1
  72. package/dist/shaders/water.js +22 -19
  73. package/dist/shaders/water.js.map +1 -1
  74. package/dist/shaders/water_medium.js +56 -0
  75. package/dist/shaders/water_medium.js.map +1 -0
  76. package/dist/shadow/shadow_region.js +49 -49
  77. package/dist/shadow/shadow_region.js.map +1 -1
  78. package/dist/utility/serialization/manager.js +3 -1
  79. package/dist/utility/serialization/manager.js.map +1 -1
  80. package/dist/utility/serialization/scene/animation.js +31 -2
  81. package/dist/utility/serialization/scene/animation.js.map +1 -1
  82. package/dist/utility/serialization/scene/camera.js +53 -25
  83. package/dist/utility/serialization/scene/camera.js.map +1 -1
  84. package/dist/utility/serialization/scene/light.js.map +1 -1
  85. package/dist/utility/serialization/scene/water.js +1101 -21
  86. package/dist/utility/serialization/scene/water.js.map +1 -1
  87. package/dist/utility/shprojector.js +87 -87
  88. package/dist/utility/shprojector.js.map +1 -1
  89. package/package.json +1 -1
  90. package/dist/render/rendergraph/frame_graph_context.js +0 -41
  91. package/dist/render/rendergraph/frame_graph_context.js.map +0 -1
  92. package/dist/shaders/ssgi.js +0 -63
  93. package/dist/shaders/ssgi.js.map +0 -1
  94. package/dist/shaders/ssgi_reprojection.js +0 -118
  95. package/dist/shaders/ssgi_reprojection.js.map +0 -1
@@ -1,17 +1,18 @@
1
- import { REVERSE_Z, Vector4, DEPTH_CLEAR_VALUE, DEPTH_COMPARE_DEFAULT } from '@zephyr3d/base';
1
+ import { Vector4, DEPTH_CLEAR_VALUE, DEPTH_COMPARE_DEFAULT } from '@zephyr3d/base';
2
2
  import { RenderQueue } from '../render_queue.js';
3
3
  import { LightPass } from '../lightpass.js';
4
4
  import { ShadowMapPass } from '../shadowmap_pass.js';
5
5
  import { DepthPass } from '../depthpass.js';
6
6
  import { ClusteredLight } from '../cluster_light.js';
7
7
  import { ShadowMaskRenderer } from '../shadow_mask_pass.js';
8
- import { buildHiZ } from '../hzb.js';
8
+ import { getHiZFormat, buildHiZ } from '../hzb.js';
9
9
  import '../primitive.js';
10
10
  import '@zephyr3d/device';
11
11
  import { CopyBlitter } from '../../blitter/copy.js';
12
12
  import { fetchSampler } from '../../utility/misc.js';
13
13
  import { MaterialVaryingFlags } from '../../values.js';
14
- import { ShaderHelper } from '../../material/shader/helper.js';
14
+ import { ShaderHelper, MAX_CAUSTIC_WATERS } from '../../material/shader/helper.js';
15
+ import { REFRACT_BLUR_MAX_LOD } from '../../material/water.js';
15
16
  import { PostEffectLayer, AbstractPostEffect } from '../../posteffect/posteffect.js';
16
17
  import { RenderGraph } from './rendergraph.js';
17
18
  import { RenderGraphExecutor } from './executor.js';
@@ -31,12 +32,16 @@ import '../../shapes/torus.js';
31
32
  import '../../shapes/plane.js';
32
33
  import '../../shapes/sphere.js';
33
34
  import '../../shapes/tetrahedron.js';
35
+ import { WaterCausticsRenderer } from '../water_caustics.js';
36
+ import { UnderwaterRenderer, selectUnderwaterSource } from '../underwater.js';
34
37
 
35
38
  const _scenePass = new LightPass();
36
39
  const _depthPass = new DepthPass();
37
40
  const _shadowMapPass = new ShadowMapPass();
38
41
  const _clusters = [];
39
42
  const _shadowMaskRenderer = new ShadowMaskRenderer();
43
+ const _waterCausticsRenderer = new WaterCausticsRenderer();
44
+ const _underwaterRenderer = new UnderwaterRenderer();
40
45
  const _devicePoolAllocator = new DevicePoolAllocator();
41
46
  const _textureAffinityCaches = new WeakMap();
42
47
  function getTextureAffinityCache(camera, device) {
@@ -210,6 +215,21 @@ function getSSSLightingTextureFormat(ctx, attachmentCount, surfaceAttachmentCoun
210
215
  function getFullMipLevelCount(width, height) {
211
216
  return Math.max(1, Math.floor(Math.log2(Math.max(1, width, height))) + 1);
212
217
  }
218
+ /**
219
+ * Levels the scene colour copy is allocated with.
220
+ *
221
+ * Deep enough to serve the refraction blur, which clamps its own LOD, and no
222
+ * deeper: the coarsest levels of a full chain are averages of the whole frame,
223
+ * which is not a plausible answer for "what is behind this water", and every
224
+ * level costs bandwidth to generate on each grab. Capped against the full chain
225
+ * so a small render target stays legal.
226
+ *
227
+ * Derived from the water material's clamp rather than restated, so the two
228
+ * cannot drift: a level count one short of it would leave the sampler asking for
229
+ * a level that does not exist.
230
+ */ function getSceneColorMipLevelCount(width, height) {
231
+ return Math.min(REFRACT_BLUR_MAX_LOD + 1, getFullMipLevelCount(width, height));
232
+ }
213
233
  function hasSurfaceMRT(ctx) {
214
234
  return !!(ctx.materialFlags & SURFACE_MRT_FLAGS);
215
235
  }
@@ -244,10 +264,16 @@ function getLightPassColorAttachments(ctx, colorAttachment) {
244
264
  const skinSSS = camera.skinSSS && renderQueueHasActiveSkinSSS(renderQueue);
245
265
  const needSceneColor = renderQueue.needSceneColor();
246
266
  const needSceneColorWithDepth = renderQueue.needSceneColorWithDepth();
267
+ // Requested by materials rather than by a post effect, so it is resolved here
268
+ // instead of in resolveFrameResourceRequirements. WebGL1 has no Hi-Z at all,
269
+ // and a material asking for one there would turn a missing feature into a
270
+ // thrown frame; the material is expected to shade without it.
271
+ const needHiZNearest = (renderQueue.needHiZNearest() || camera.HiZNearest) && deviceType !== 'webgl';
247
272
  return {
248
273
  depthPrepass: true,
249
274
  motionVectors: false,
250
- hiZ: false,
275
+ hiZ: needHiZNearest,
276
+ hiZNearest: needHiZNearest,
251
277
  sceneNormal: false,
252
278
  sceneRoughness: false,
253
279
  shadowMask: false,
@@ -266,7 +292,10 @@ function getLightPassColorAttachments(ctx, colorAttachment) {
266
292
  function resolveFrameResourceRequirements(ctx, options, requirements) {
267
293
  const deviceType = ctx.device.type;
268
294
  options.motionVectors ||= !!requirements.motionVector;
269
- options.hiZ ||= !!requirements.hiZ;
295
+ options.hiZNearest ||= !!requirements.hiZNearest;
296
+ // The nearest channel lives on the same pyramid, so asking for it is asking
297
+ // for the pyramid.
298
+ options.hiZ ||= !!requirements.hiZ || options.hiZNearest;
270
299
  options.sceneNormal ||= !!requirements.sceneNormal;
271
300
  options.sceneRoughness ||= !!requirements.sceneRoughness;
272
301
  options.shadowMask ||= !!requirements.shadowMask;
@@ -401,6 +430,152 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
401
430
  });
402
431
  }
403
432
  };
433
+ /**
434
+ * Picks the water surface and light the caustic map is built from.
435
+ *
436
+ * Only one pair is handled per frame: the caustics ride on a single light's
437
+ * additive pass, and a second water body would need a second map. The largest
438
+ * visible water surface wins, which is the one a camera near several of them is
439
+ * most likely to be looking through.
440
+ * @internal
441
+ */ function selectWaterCausticSource(ctx, renderQueue) {
442
+ if (ctx.device.type === 'webgl' || renderQueue.waters.length === 0) {
443
+ return null;
444
+ }
445
+ // The caustic light must own an additive shadow pass, because that is the pass
446
+ // the caustic uniforms are declared and bound on.
447
+ const candidate = renderQueue.sunLight ?? renderQueue.primaryDirectionalLight;
448
+ const light = candidate && candidate.castShadow && renderQueue.shadowedLights.indexOf(candidate) >= 0 ? candidate : null;
449
+ if (!light) {
450
+ return null;
451
+ }
452
+ const waters = renderQueue.waters.filter((water)=>WaterCausticsRenderer.canRender(water, light));
453
+ if (waters.length === 0) {
454
+ return null;
455
+ }
456
+ // Largest first, which decides both what survives the cap and which body's
457
+ // settings describe the shared map. Area is a stand-in for "the one the viewer
458
+ // is most likely to be over"; a camera-distance sort would swap the map's
459
+ // whole configuration as the viewer walks between two pools, and swapping it
460
+ // discards the temporal history every time.
461
+ waters.sort((a, b)=>{
462
+ const ra = a.material.region;
463
+ const rb = b.material.region;
464
+ const areaA = Math.max(0, ra.z - ra.x) * Math.max(0, ra.w - ra.y);
465
+ const areaB = Math.max(0, rb.z - rb.x) * Math.max(0, rb.w - rb.y);
466
+ return areaB - areaA;
467
+ });
468
+ return {
469
+ waters: waters.slice(0, MAX_CAUSTIC_WATERS),
470
+ light
471
+ };
472
+ }
473
+ /** @internal */ const WaterCausticsModule = {
474
+ type: 'WaterCaustics',
475
+ writes: [
476
+ FrameResources.WaterCaustics
477
+ ],
478
+ prepare: ({ ctx, renderQueue })=>({
479
+ enabled: !!selectWaterCausticSource(ctx, renderQueue)
480
+ }),
481
+ setup (fg) {
482
+ const { graph, ctx, renderQueue, ordering, blackboard } = fg;
483
+ const source = selectWaterCausticSource(ctx, renderQueue);
484
+ // Map-wide settings come from the largest body; see selectWaterCausticSource.
485
+ const material = source.waters[0].material;
486
+ const size = material.causticsResolution;
487
+ const format = WaterCausticsRenderer.getMapFormat(ctx.device);
488
+ const history = fg.history;
489
+ const temporal = material.causticsTemporalStrength > 0;
490
+ const mapDesc = {
491
+ format,
492
+ sizeMode: 'absolute',
493
+ width: size,
494
+ height: size
495
+ };
496
+ const mapSize = {
497
+ width: size,
498
+ height: size
499
+ };
500
+ // Null on the first frame, after a resize, and whenever the map format
501
+ // changes; the renderer falls back to the current frame alone.
502
+ const previousHandle = temporal && history ? history.importPreviousIfCompatible(graph, RGHistoryResources.WATER_CAUSTICS, mapDesc, mapSize) : null;
503
+ const result = graph.addPass('WaterCaustics', (builder)=>{
504
+ // The wave textures the splat pass samples are updated by the scene, not by
505
+ // the graph, so chain after the passes that have already run rather than
506
+ // declaring a resource dependency on them.
507
+ ordering.chainInto(builder);
508
+ ordering.emit(builder, 'WaterCausticsDone');
509
+ // The map leaves the graph through the draw context rather than through a
510
+ // handle a later pass samples, so nothing would otherwise keep this alive.
511
+ builder.sideEffect();
512
+ // Fixed-size, unlike almost every other graph texture: the map covers a
513
+ // world-space footprint, not the screen.
514
+ const mapHandle = builder.createTexture({
515
+ format,
516
+ sizeMode: 'absolute',
517
+ width: size,
518
+ height: size,
519
+ label: 'waterCaustics',
520
+ allocationKey: 'ForwardPlus.WaterCaustics'
521
+ });
522
+ const scratchHandle = builder.createTexture({
523
+ format,
524
+ sizeMode: 'absolute',
525
+ width: size,
526
+ height: size,
527
+ label: 'waterCausticsScratch',
528
+ allocationKey: 'ForwardPlus.WaterCausticsScratch'
529
+ });
530
+ // The displaced surface height along each map ray, which the receiver
531
+ // gates its depth on. Same slice and size as the map so it shares the
532
+ // map's uv; four channels, one per water slot.
533
+ const heightHandle = builder.createTexture({
534
+ format: WaterCausticsRenderer.getHeightMapFormat(),
535
+ sizeMode: 'absolute',
536
+ width: size,
537
+ height: size,
538
+ label: 'waterCausticHeights',
539
+ allocationKey: 'ForwardPlus.WaterCausticHeights'
540
+ });
541
+ if (previousHandle) {
542
+ builder.read(previousHandle);
543
+ }
544
+ builder.setExecute((rgCtx)=>{
545
+ const map = rgCtx.getTexture(mapHandle);
546
+ const scratch = rgCtx.getTexture(scratchHandle);
547
+ const heights = rgCtx.getTexture(heightHandle);
548
+ const previous = previousHandle ? rgCtx.getTexture(previousHandle) : null;
549
+ const resolved = _waterCausticsRenderer.render(ctx, source.waters, source.light, map, scratch, heights, previous, (texture)=>rgCtx.createFramebuffer({
550
+ width: texture.width,
551
+ height: texture.height,
552
+ colorAttachments: texture,
553
+ depthAttachment: null
554
+ }));
555
+ if (temporal && history) {
556
+ // Commit whichever texture the renderer settled on, so the next frame
557
+ // reprojects the map that was actually shown rather than the
558
+ // pre-resolve one.
559
+ history.queueRetainedCommit(RGHistoryResources.WATER_CAUSTICS, mapDesc, mapSize, resolved);
560
+ }
561
+ // Publish only after the map exists: the light pass keys its shader
562
+ // variant on this flag and binds the texture in the same step.
563
+ ctx.waterCaustics = true;
564
+ ctx.waterCausticLight = source.light;
565
+ ctx.waterCausticTexture = resolved;
566
+ ctx.waterCausticHeightTexture = heights;
567
+ ctx.waterCausticUniforms = _waterCausticsRenderer.uniforms;
568
+ });
569
+ return {
570
+ mapHandle,
571
+ scratchHandle
572
+ };
573
+ });
574
+ // Which target holds the result depends on whether the resolve ran. Both are
575
+ // declared by this pass, so either is a valid handle for consumers to read.
576
+ blackboard.set(FrameResources.WaterCaustics, temporal && previousHandle ? result.scratchHandle : result.mapHandle);
577
+ }
578
+ };
404
579
  /** @internal */ const DepthPrepassModule = {
405
580
  type: 'DepthPrepass',
406
581
  writes: [
@@ -580,7 +755,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
580
755
  enabled: options.hiZ
581
756
  }),
582
757
  setup (fg) {
583
- const { graph, ctx, frame, blackboard } = fg;
758
+ const { graph, ctx, frame, blackboard, options } = fg;
584
759
  const depthPassResult = requireBuildState(fg, 'depth', 'DepthPrepass', 'HiZ');
585
760
  const preLightTransmissionDepthToken = fg.state.preLightTransmissionDepthToken;
586
761
  //const hiZWidth = nextPowerOf2(ctx.renderWidth);
@@ -593,11 +768,14 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
593
768
  builder.read(preLightTransmissionDepthToken);
594
769
  }
595
770
  hiZHandle = builder.createTexture({
596
- format: REVERSE_Z ? 'r16f' : 'r32f',
771
+ format: getHiZFormat(options.hiZNearest),
597
772
  label: 'hiZ',
598
773
  sizeMode: 'backbuffer-relative',
599
774
  mipLevels: getFullMipLevelCount(ctx.renderWidth, ctx.renderHeight),
600
- allocationKey: 'ForwardPlus.HiZ'
775
+ // Keyed on the channel count: a one- and a two-channel pyramid are
776
+ // different textures, and pooling them under one key would hand a
777
+ // consumer a pyramid with no nearest channel in it.
778
+ allocationKey: `ForwardPlus.HiZ${options.hiZNearest ? '.MinMax' : ''}`
601
779
  });
602
780
  const hiZFramebufferHandle = builder.createFramebuffer({
603
781
  label: 'HiZFramebuffer',
@@ -633,6 +811,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
633
811
  const depthPassResult = requireBuildState(fg, 'depth', 'DepthPrepass', 'SSSProfile');
634
812
  const preLightTransmissionDepthToken = fg.state.preLightTransmissionDepthToken;
635
813
  const shadowMaskHandle = blackboard.get(FrameResources.ShadowMask);
814
+ const waterCausticsHandle = blackboard.get(FrameResources.WaterCaustics);
636
815
  const renderDepthAttachment = fg.state.renderDepthAttachment;
637
816
  const sssProfileResult = graph.addPass('SSSProfile', (builder)=>{
638
817
  builder.read(blackboard.expect(FrameResources.LinearDepth));
@@ -643,6 +822,12 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
643
822
  if (shadowMaskHandle) {
644
823
  builder.read(shadowMaskHandle);
645
824
  }
825
+ if (waterCausticsHandle) {
826
+ // Same reason as the shadow mask: the map reaches the shader through
827
+ // ctx.waterCausticTexture, so only a declared read stops the executor
828
+ // from recycling it while this pass is still sampling it.
829
+ builder.read(waterCausticsHandle);
830
+ }
646
831
  const profileHandle = builder.createTexture({
647
832
  format: 'rgba16f',
648
833
  label: 'sssProfile',
@@ -691,6 +876,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
691
876
  const depthPassResult = requireBuildState(fg, 'depth', 'DepthPrepass', 'SceneColorGrab');
692
877
  const preLightTransmissionDepthToken = fg.state.preLightTransmissionDepthToken;
693
878
  const shadowMaskHandle = blackboard.get(FrameResources.ShadowMask);
879
+ const waterCausticsHandle = blackboard.get(FrameResources.WaterCaustics);
694
880
  const renderDepthAttachment = fg.state.renderDepthAttachment;
695
881
  const grabResult = graph.addPass('SceneColorGrab', (builder)=>{
696
882
  builder.read(blackboard.expect(FrameResources.LinearDepth));
@@ -701,9 +887,34 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
701
887
  if (shadowMaskHandle) {
702
888
  builder.read(shadowMaskHandle);
703
889
  }
890
+ if (waterCausticsHandle) {
891
+ // Same reason as the shadow mask: the map reaches the shader through
892
+ // ctx.waterCausticTexture, so only a declared read stops the executor
893
+ // from recycling it while this pass is still sampling it.
894
+ builder.read(waterCausticsHandle);
895
+ }
896
+ // A few mip levels, because the refraction background is sampled at a LOD
897
+ // and not only at mip 0: water picks one from how deep and how turbid the
898
+ // column is, so a stone under clear shallows stays sharp while the same
899
+ // stone under metres of silt smears. With a single-level texture the
900
+ // sampler returned mip 0 whatever LOD was asked for, and the blur did
901
+ // nothing.
902
+ //
903
+ // Nothing here generates the chain - both backends do it when the
904
+ // framebuffer is unbound, for any colour attachment with more than one
905
+ // level (WebGL `framebuffer_webgl.ts` unbind, WebGPU `renderpass_webgpu.ts`
906
+ // end), and `generateMipmaps` defaults to true on colour attachments. The
907
+ // level count is the whole precondition.
908
+ //
909
+ // Not the full chain down to 1x1. The consumer clamps its LOD, and the
910
+ // coarsest levels of a full chain average across the whole frame - a
911
+ // sample from one stops being "what is behind the water" and becomes the
912
+ // mean of the image. Generating them would cost bandwidth every frame to
913
+ // produce levels nothing may read.
704
914
  const copyHandle = builder.createTexture({
705
915
  format: ctx.colorFormat,
706
916
  label: 'sceneColorCopy',
917
+ mipLevels: getSceneColorMipLevelCount(ctx.renderWidth, ctx.renderHeight),
707
918
  allocationKey: 'ForwardPlus.SceneColorCopy'
708
919
  });
709
920
  // Isolate depth when SSR inserts transmission depth before LightPass.
@@ -744,6 +955,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
744
955
  const { graph, ctx, frame, blackboard, options, backbuffer } = fg;
745
956
  const depthPassResult = requireBuildState(fg, 'depth', 'DepthPrepass', 'LightPass');
746
957
  const shadowMaskHandle = blackboard.get(FrameResources.ShadowMask);
958
+ const waterCausticsHandle = blackboard.get(FrameResources.WaterCaustics);
747
959
  const preLightTransmissionDepthToken = fg.state.preLightTransmissionDepthToken;
748
960
  const hiZHandle = blackboard.get(FrameResources.HiZ);
749
961
  const sssProfileHandle = blackboard.get(FrameResources.SSSProfile);
@@ -814,6 +1026,12 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
814
1026
  if (shadowMaskHandle) {
815
1027
  builder.read(shadowMaskHandle);
816
1028
  }
1029
+ if (waterCausticsHandle) {
1030
+ // Same reason as the shadow mask: the map reaches the shader through
1031
+ // ctx.waterCausticTexture, so only a declared read stops the executor
1032
+ // from recycling it while this pass is still sampling it.
1033
+ builder.read(waterCausticsHandle);
1034
+ }
817
1035
  if (preLightTransmissionDepthToken) {
818
1036
  builder.read(preLightTransmissionDepthToken);
819
1037
  }
@@ -1001,6 +1219,68 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1001
1219
  }
1002
1220
  }
1003
1221
  };
1222
+ /** @internal */ const UnderwaterModule = {
1223
+ type: 'Underwater',
1224
+ reads: [
1225
+ {
1226
+ resource: FrameResources.SceneColor,
1227
+ version: 'current'
1228
+ }
1229
+ ],
1230
+ writes: [
1231
+ FrameResources.SceneColor
1232
+ ],
1233
+ prepare: ({ ctx })=>({
1234
+ enabled: !!ctx.underwater
1235
+ }),
1236
+ setup (fg) {
1237
+ const { graph, ctx, frame, blackboard } = fg;
1238
+ const depthPassResult = requireBuildState(fg, 'depth', 'DepthPrepass', 'Underwater');
1239
+ const lightPassResult = requireBuildState(fg, 'lightPass', 'LightPass', 'Underwater');
1240
+ const sceneColorHandle = blackboard.expect(FrameResources.SceneColor);
1241
+ const waterCausticsHandle = blackboard.get(FrameResources.WaterCaustics);
1242
+ const state = ctx.underwater;
1243
+ // After the sky, before the transparent pass: the medium has to cover the
1244
+ // sky - that is what the horizon fades into underwater - and the opaque
1245
+ // scene is the only thing the depth buffer describes, so it is also the only
1246
+ // thing this can reach. Transparent geometry is on its own, exactly as it is
1247
+ // for atmospheric fog.
1248
+ const underwaterColorHandle = graph.addPass('Underwater', (builder)=>{
1249
+ builder.read(sceneColorHandle);
1250
+ builder.read(depthPassResult.depthFramebufferHandle);
1251
+ if (lightPassResult.sceneColorFramebufferHandle) {
1252
+ builder.read(lightPassResult.sceneColorFramebufferHandle);
1253
+ }
1254
+ if (waterCausticsHandle) {
1255
+ // The light shafts sample the caustic map, which reaches the shader
1256
+ // through ctx.waterCausticTexture rather than through a handle. Same
1257
+ // lifetime argument as the shadow mask in CompositeTail: only a declared
1258
+ // read stops the executor returning it to the pool after LightPass.
1259
+ builder.read(waterCausticsHandle);
1260
+ }
1261
+ const out = builder.write(sceneColorHandle);
1262
+ builder.setExecute((rgCtx)=>{
1263
+ const colorTexture = rgCtx.getTexture(sceneColorHandle);
1264
+ const depthTexture = frame.depthFramebuffer?.getDepthAttachment();
1265
+ if (!colorTexture || !depthTexture) {
1266
+ return;
1267
+ }
1268
+ // Color only. The scene depth is sampled by this pass, which it cannot
1269
+ // be while it is still attached to the bound framebuffer.
1270
+ const colorFramebuffer = rgCtx.createFramebuffer({
1271
+ width: colorTexture.width,
1272
+ height: colorTexture.height,
1273
+ colorAttachments: colorTexture,
1274
+ depthAttachment: null
1275
+ });
1276
+ _underwaterRenderer.render(ctx, state, colorFramebuffer, depthTexture);
1277
+ });
1278
+ return out;
1279
+ });
1280
+ lightPassResult.sceneColorHandle = underwaterColorHandle;
1281
+ blackboard.set(FrameResources.SceneColor, underwaterColorHandle);
1282
+ }
1283
+ };
1004
1284
  /** @internal */ const CompositeTailModule = {
1005
1285
  type: 'CompositeTail',
1006
1286
  writes: [
@@ -1017,6 +1297,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1017
1297
  const hiZHandle = blackboard.get(FrameResources.HiZ);
1018
1298
  const sceneColorCopyHandle = blackboard.get(FrameResources.SceneColorCopy);
1019
1299
  const shadowMaskHandle = blackboard.get(FrameResources.ShadowMask);
1300
+ const waterCausticsHandle = blackboard.get(FrameResources.WaterCaustics);
1020
1301
  const lightPassResult = requireBuildState(fg, 'lightPass', 'LightPass', 'CompositeTail');
1021
1302
  const renderDepthAttachment = fg.state.renderDepthAttachment;
1022
1303
  const useFinalFramebufferAsIntermediate = fg.state.useFinalFramebufferAsIntermediate;
@@ -1090,6 +1371,10 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1090
1371
  // after LightPass while this pass is still sampling it.
1091
1372
  builder.read(shadowMaskHandle);
1092
1373
  }
1374
+ if (waterCausticsHandle) {
1375
+ // Same lifetime argument as the shadow mask above.
1376
+ builder.read(waterCausticsHandle);
1377
+ }
1093
1378
  builder.read(opaqueChainResult.color);
1094
1379
  const out = builder.write(opaqueChainResult.color);
1095
1380
  builder.setExecute((rgCtx)=>{
@@ -1205,6 +1490,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1205
1490
  ClusterLights: ClusterLightsModule,
1206
1491
  GPUPicking: GPUPickingModule,
1207
1492
  ShadowMaps: ShadowMapsModule,
1493
+ WaterCaustics: WaterCausticsModule,
1208
1494
  DepthPrepass: DepthPrepassModule,
1209
1495
  ShadowMask: ShadowMaskModule,
1210
1496
  TransmissionDepthForSSR: TransmissionDepthForSSRModule,
@@ -1213,6 +1499,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1213
1499
  SceneColorGrab: SceneColorGrabModule,
1214
1500
  LightPass: LightPassModule,
1215
1501
  SkyPass: SkyPassModule,
1502
+ Underwater: UnderwaterModule,
1216
1503
  CompositeTail: CompositeTailModule
1217
1504
  };
1218
1505
  /** The default module order assembled by {@link createForwardPlusPipeline}. */ const DEFAULT_FORWARD_PLUS_MODULES = [
@@ -1220,6 +1507,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1220
1507
  ClusterLightsModule,
1221
1508
  GPUPickingModule,
1222
1509
  ShadowMapsModule,
1510
+ WaterCausticsModule,
1223
1511
  DepthPrepassModule,
1224
1512
  ShadowMaskModule,
1225
1513
  TransmissionDepthForSSRModule,
@@ -1228,6 +1516,7 @@ function validateProducedFrameResources(blackboard, options, renderQueue) {
1228
1516
  SceneColorGrabModule,
1229
1517
  LightPassModule,
1230
1518
  SkyPassModule,
1519
+ UnderwaterModule,
1231
1520
  CompositeTailModule
1232
1521
  ];
1233
1522
  /** @internal */ let _defaultForwardPlusPipeline = null;
@@ -1255,6 +1544,13 @@ function buildForwardPlusGraphInternal(graph, ctx, renderQueue, options) {
1255
1544
  ctx.SkinSSSTexture = null;
1256
1545
  // ShadowMask sets this only when it produces a texture.
1257
1546
  ctx.shadowMaskTexture = null;
1547
+ // WaterCaustics turns these on together once it has produced a map; they are
1548
+ // read by every light pass, so they must start cleared each frame.
1549
+ ctx.waterCaustics = false;
1550
+ ctx.waterCausticLight = null;
1551
+ ctx.waterCausticTexture = null;
1552
+ ctx.waterCausticHeightTexture = null;
1553
+ ctx.waterCausticUniforms = null;
1258
1554
  const blackboard = new RGBlackboard();
1259
1555
  const frame = {
1260
1556
  ctx,
@@ -1740,7 +2036,11 @@ function blitToCurrentColorAttachment(ctx, source) {
1740
2036
  device.setViewport(null);
1741
2037
  device.setScissor(null);
1742
2038
  ctx.scene.env.sky.renderSky(ctx);
1743
- if (ctx.scene.env.sky.fogPresents) {
2039
+ // Not while submerged: the medium between the eye and the scene is water,
2040
+ // and the Underwater module composites it. The sky itself is still drawn -
2041
+ // that pass then extinguishes it along with everything else at the far
2042
+ // plane, which is what makes the horizon read as water rather than as air.
2043
+ if (ctx.scene.env.sky.fogPresents && !ctx.underwater) {
1744
2044
  // Snapshot between sky and fog. Sky belongs in the copy - a ray that hits
1745
2045
  // the sky should read the sky - while fog does not, because the fog a
1746
2046
  // screen-space pass would sample lies along the camera ray rather than the
@@ -1822,8 +2122,17 @@ function blitToCurrentColorAttachment(ctx, source) {
1822
2122
  let motionVectorFrameCommitted = false;
1823
2123
  try {
1824
2124
  renderQueue = _scenePass.cullScene(ctx, ctx.camera);
2125
+ // Before the options are derived, because being submerged changes them: the
2126
+ // atmosphere is not the medium the camera is looking through, so its fog is
2127
+ // suppressed for the frame.
2128
+ ctx.underwater = selectUnderwaterSource(ctx);
1825
2129
  const options = deriveForwardPlusOptions(ctx.scene, ctx.camera, device.type, renderQueue);
1826
2130
  options.ssgi &&= supportsSSGIRenderTargets(ctx);
2131
+ // Atmospheric fog describes a column of air, which is not what a submerged
2132
+ // camera is looking through. The Underwater module owns the medium instead.
2133
+ // Clearing the flag here also stops SkyPass reserving a fog-free snapshot
2134
+ // for a frame that composites no fog.
2135
+ options.fogPresents &&= !ctx.underwater;
1827
2136
  ctx.SSS = options.sss;
1828
2137
  historyManager = ctx.camera.getHistoryResourceManager();
1829
2138
  if (!historyManager) {