@marble.fun/track-catalog 3.0.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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/assets.json +346 -0
  4. package/package.json +32 -0
  5. package/release.json +135 -0
  6. package/src/audio/game-audio-preference.ts +9 -0
  7. package/src/authoritative.ts +6 -0
  8. package/src/catalog.ts +1 -0
  9. package/src/index.ts +14 -0
  10. package/src/legacy-stock-catalog.ts +130 -0
  11. package/src/pages/lobby/lobby-types.ts +140 -0
  12. package/src/pages/lobby/marble-factory.ts +931 -0
  13. package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
  14. package/src/pages/play/race/egypt-portal-frame.ts +382 -0
  15. package/src/pages/play/race/finish-detection.ts +1 -0
  16. package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
  17. package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
  18. package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
  19. package/src/pages/play/race/jungle-portal-frame.ts +308 -0
  20. package/src/pages/play/race/jungle-temple-lift.ts +366 -0
  21. package/src/pages/play/race/marble-entity.ts +257 -0
  22. package/src/pages/play/race/marble-material.ts +20 -0
  23. package/src/pages/play/race/marble-race-engine.ts +2652 -0
  24. package/src/pages/play/race/path-pattern-layout.ts +142 -0
  25. package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
  26. package/src/pages/play/race/physics-debug.ts +1 -0
  27. package/src/pages/play/race/physics-dry-run.ts +90 -0
  28. package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
  29. package/src/pages/play/race/portal-pair-color.ts +20 -0
  30. package/src/pages/play/race/portal-proportions.ts +25 -0
  31. package/src/pages/play/race/portal-visual.ts +1108 -0
  32. package/src/pages/play/race/race-camera-director.ts +586 -0
  33. package/src/pages/play/race/race-camera-obstruction.ts +37 -0
  34. package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
  35. package/src/pages/play/race/race-camera.ts +206 -0
  36. package/src/pages/play/race/race-completion.ts +5 -0
  37. package/src/pages/play/race/race-configuration.ts +57 -0
  38. package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
  39. package/src/pages/play/race/race-physics-profile.ts +7 -0
  40. package/src/pages/play/race/race-physics.ts +16 -0
  41. package/src/pages/play/race/race-playback.ts +41 -0
  42. package/src/pages/play/race/race-rolling-audio.ts +616 -0
  43. package/src/pages/play/race/race-simulation-renderer.ts +221 -0
  44. package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
  45. package/src/pages/play/race/race-transform-interpolation.ts +50 -0
  46. package/src/pages/play/race/race-types.ts +12 -0
  47. package/src/pages/play/race/rebound-visual.ts +70 -0
  48. package/src/pages/play/race/seeded-random.ts +23 -0
  49. package/src/pages/play/race/space-station-portal-frame.ts +275 -0
  50. package/src/pages/play/race/track-appearance-canopy.ts +946 -0
  51. package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
  52. package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
  53. package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
  54. package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
  55. package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
  56. package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
  57. package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
  58. package/src/pages/play/race/track-appearance-support.ts +983 -0
  59. package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
  60. package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
  61. package/src/pages/play/race/track-course.ts +271 -0
  62. package/src/pages/play/race/track-definition.ts +443 -0
  63. package/src/pages/play/race/track-fence-architecture.ts +33 -0
  64. package/src/pages/play/race/track-floor-material-rules.ts +51 -0
  65. package/src/pages/play/race/track-mechanisms.ts +1350 -0
  66. package/src/pages/play/race/track-physics-surface.ts +6 -0
  67. package/src/pages/play/race/track-route-network.ts +9 -0
  68. package/src/pages/play/race/track-shadow-batching.ts +760 -0
  69. package/src/pages/play/race/track-surface-basis.ts +1 -0
  70. package/src/pages/play/race/track-surface-topology.ts +326 -0
  71. package/src/pages/play/race/track-world.ts +10181 -0
  72. package/src/pages/play/race/under-track-support-architecture.ts +122 -0
  73. package/src/pages/play/race/vortex-profile.ts +5 -0
  74. package/src/pages/play/race/vortex-water-effect.ts +863 -0
  75. package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
  76. package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
  77. package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
  78. package/src/pages/track-lab/cone-field-layout.ts +1 -0
  79. package/src/pages/track-lab/conveyor-layout.ts +1 -0
  80. package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
  81. package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
  82. package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
  83. package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
  84. package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
  85. package/src/pages/track-lab/flipper-component.ts +127 -0
  86. package/src/pages/track-lab/rising-wall-layout.ts +1 -0
  87. package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
  88. package/src/pages/track-lab/track-piece-layout.ts +28 -0
  89. package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
  90. package/src/three/geometry-attribute-aliases.ts +24 -0
  91. package/src/three/geometry-detail.ts +36 -0
  92. package/src/three/homepage-marble-geometry.ts +15 -0
  93. package/src/three/japanese-sakura-appearance.ts +252 -0
  94. package/src/three/jungle-flower-hedge-appearance.ts +7 -0
  95. package/src/three/jungle-temple-appearance.ts +5 -0
  96. package/src/three/low-quality-scene-renderer.ts +132 -0
  97. package/src/three/marble-sphere-geometry.ts +13 -0
  98. package/src/three/mobile-rendering-budget.ts +11 -0
  99. package/src/three/painted-valley-appearance.ts +270 -0
  100. package/src/three/railroad-appearance.ts +71 -0
  101. package/src/three/renderer-resize.ts +32 -0
  102. package/src/three/rope-wall-appearance.ts +91 -0
  103. package/src/three/rugged-track-appearance.ts +2 -0
  104. package/src/three/scene-lighting.ts +183 -0
  105. package/src/three/scene-shader-preparation.ts +64 -0
  106. package/src/three/scene-theme-controller.ts +461 -0
  107. package/src/three/shadow-shader-preparation.ts +162 -0
  108. package/src/three/shared-obstacle-metal.ts +44 -0
  109. package/src/three/soft-additive-particles.ts +57 -0
  110. package/src/three/space-station-appearance.ts +213 -0
  111. package/src/three/static-track-batched-mesh.ts +132 -0
  112. package/src/three/stone-track-shell-appearance.ts +12 -0
  113. package/src/three/theme-ambience-controller.ts +226 -0
  114. package/src/three/theme-asset-cache.ts +120 -0
  115. package/src/three/theme-audio-preference.ts +43 -0
  116. package/src/three/theme-catalog.ts +1457 -0
  117. package/src/three/theme-material-assets.ts +258 -0
  118. package/src/three/theme-material-ktx2-manifest.ts +190 -0
  119. package/src/three/theme-material-registry.ts +1952 -0
  120. package/src/three/theme-world-factory.ts +590 -0
  121. package/src/three/track-connector-batch.ts +125 -0
  122. package/src/three/track-material-continuity.ts +442 -0
  123. package/src/three/track-theme.ts +535 -0
  124. package/src/three/vampire-castle-appearance.ts +258 -0
  125. package/src/three/visual-frame-pacing.ts +19 -0
  126. package/src/three/visual-geometry.ts +120 -0
  127. package/src/three/visual-quality.ts +83 -0
  128. package/src/three/water-appearance.ts +29 -0
  129. package/src/three/wild-west-sand-appearance.ts +2 -0
@@ -0,0 +1,863 @@
1
+ import { geometryDetailSegments } from '../../../three/geometry-detail'
2
+ import * as THREE from 'three'
3
+
4
+ import { VisualCylinderGeometry, VisualTorusGeometry } from '../../../three/visual-geometry'
5
+
6
+ import { FLOWING_WATER_APPEARANCE_ID, type FluidSurfaceStyle } from '../../../three/water-appearance'
7
+ import type { TrackThemeDefinition } from '../../../three/track-theme'
8
+
9
+ // Every water-capable piece raises the rendered sheet by the same small
10
+ // amount above its canonical water plane to avoid z-fighting at connections.
11
+ export const TRACK_WATER_SURFACE_OFFSET = 0.035
12
+ export const WATER_ANIMATION_SPEED_SCALE = 1.4
13
+
14
+ export interface FlowingWaterSurfaceEffectOptions {
15
+ pieceId: string
16
+ geometry: THREE.BufferGeometry
17
+ waveAmplitude: number
18
+ startAngle?: number
19
+ flowDirection?: -1 | 1
20
+ opacity?: number
21
+ speed?: number
22
+ laneMode?: boolean
23
+ connectionPhase?: number
24
+ name?: string
25
+ style?: FluidSurfaceStyle
26
+ themeId?: string
27
+ }
28
+
29
+ export interface VortexWaterEffectOptions {
30
+ pieceId: string
31
+ center: THREE.Vector3 | readonly [number, number, number]
32
+ innerRadius: number
33
+ outerRadius: number
34
+ surfaceHeightAtRadius: (radius: number) => number
35
+ startAngle?: number
36
+ orbitDirection?: -1 | 1
37
+ dropLength?: number
38
+ entryTransition?: readonly VortexWaterEntryTransitionColumn[]
39
+ entryConnected?: boolean
40
+ centerFunnel?: readonly VortexWaterFunnelRing[]
41
+ style?: FluidSurfaceStyle
42
+ themeId?: string
43
+ }
44
+
45
+ export interface VortexWaterEntryTransitionColumn {
46
+ start: THREE.Vector3 | readonly [number, number, number]
47
+ collar: THREE.Vector3 | readonly [number, number, number]
48
+ boundary: THREE.Vector3 | readonly [number, number, number]
49
+ }
50
+
51
+ export interface VortexWaterFunnelRing {
52
+ radius: number
53
+ height: number
54
+ }
55
+
56
+ const SURFACE_ANGULAR_SEGMENTS = geometryDetailSegments(24, 8)
57
+ // Six rings retain a row inside the inlet foam transition (>80% radius).
58
+ const SURFACE_RADIAL_SEGMENTS = 6
59
+
60
+ interface FluidPalette {
61
+ id: string
62
+ deep: number
63
+ shallow: number
64
+ foam: number
65
+ accent: number
66
+ accentStrength: number
67
+ drop: number
68
+ mist: number
69
+ }
70
+
71
+ const NATURAL_WATER_PALETTE: FluidPalette = {
72
+ id: 'natural-water',
73
+ deep: 0x0d4541,
74
+ shallow: 0x347367,
75
+ foam: 0xf4f7f0,
76
+ accent: 0x347367,
77
+ accentStrength: 0,
78
+ drop: 0x55cfe9,
79
+ mist: 0xd6faff,
80
+ }
81
+
82
+ function fluidPaletteForTheme(themeId: string | undefined): FluidPalette {
83
+ if (themeId === 'intergalactic-space-station') {
84
+ return {
85
+ id: 'space-station-pink',
86
+ deep: 0x5a0a46,
87
+ shallow: 0xf337b4,
88
+ foam: 0xffd9f5,
89
+ accent: 0xff8edb,
90
+ accentStrength: 0.68,
91
+ drop: 0xf337b4,
92
+ mist: 0xffd9f5,
93
+ }
94
+ }
95
+ if (themeId === 'vampire-castle') {
96
+ return {
97
+ id: 'vampire-blood-red',
98
+ deep: 0x260307,
99
+ shallow: 0x8f101c,
100
+ foam: 0xffc7c9,
101
+ accent: 0xd62f3b,
102
+ accentStrength: 0.42,
103
+ drop: 0x9d101d,
104
+ mist: 0xffb5b9,
105
+ }
106
+ }
107
+ return NATURAL_WATER_PALETTE
108
+ }
109
+
110
+ /**
111
+ * Adds a cheap, visual-only water overlay to a vortex bowl.
112
+ *
113
+ * The returned group owns all effect geometry, materials, and render-time wave
114
+ * animation. It has no physics body or collider, so removing the call removes
115
+ * the effect completely.
116
+ */
117
+ export function addVortexWaterEffect(root: THREE.Object3D, options: VortexWaterEffectOptions): THREE.Group {
118
+ assertVortexWaterEffectOptions(options)
119
+
120
+ const center = options.center instanceof THREE.Vector3 ? options.center.clone() : new THREE.Vector3(...options.center)
121
+ const palette = fluidPaletteForTheme(options.themeId)
122
+ const effect = new THREE.Group()
123
+ effect.name = `${options.pieceId}-spiral-waterfall`
124
+ effect.position.copy(center)
125
+ markAsDecoration(effect)
126
+
127
+ const surfaceGeometry = createWaterSurfaceGeometry(options, center.y)
128
+ addFlowingWaterSurfaceEffect(effect, {
129
+ pieceId: options.pieceId,
130
+ geometry: surfaceGeometry,
131
+ waveAmplitude: THREE.MathUtils.clamp(options.outerRadius * 0.006, 0.035, 0.075),
132
+ flowDirection: options.orbitDirection,
133
+ startAngle: options.startAngle,
134
+ opacity: 0.58,
135
+ speed: 2.4,
136
+ style: options.style,
137
+ themeId: options.themeId,
138
+ name: `${options.pieceId}-water-surface`,
139
+ })
140
+
141
+ const funnelThroat = options.centerFunnel?.[0]
142
+ const innerSurfaceHeight = funnelThroat?.height ?? options.surfaceHeightAtRadius(options.innerRadius * 1.02)
143
+ const bowlDrop = Math.abs(
144
+ options.surfaceHeightAtRadius(options.outerRadius) - options.surfaceHeightAtRadius(options.innerRadius),
145
+ )
146
+ const dropLength = options.dropLength ?? THREE.MathUtils.clamp(bowlDrop * 0.34, 1.4, 3.2)
147
+ const dropTop = innerSurfaceHeight - center.y + 0.03
148
+ const dropTopRadius = Math.max(0.12, funnelThroat?.radius ?? options.innerRadius * 0.68)
149
+ const dropBottomRadius = Math.max(0.1, dropTopRadius * 0.72)
150
+ const dropGeometry = new VisualCylinderGeometry(dropTopRadius, dropBottomRadius, dropLength, 18, 3, true)
151
+ const dropMaterial = new THREE.MeshBasicMaterial({
152
+ color: palette.drop,
153
+ depthFunc: THREE.LessEqualDepth,
154
+ depthTest: true,
155
+ depthWrite: false,
156
+ opacity: 0.42,
157
+ side: THREE.FrontSide,
158
+ toneMapped: true,
159
+ transparent: true,
160
+ })
161
+ dropMaterial.name = 'vortex-water-drop-material'
162
+ const drop = new THREE.Mesh(dropGeometry, dropMaterial)
163
+ drop.name = `${options.pieceId}-spiral-water-drop`
164
+ drop.position.y = dropTop - dropLength / 2
165
+ drop.renderOrder = 5
166
+ markAsDecoration(drop, true)
167
+ drop.userData.applySemanticTheme = (theme: TrackThemeDefinition) => {
168
+ const nextPalette = fluidPaletteForTheme(theme.id)
169
+ dropMaterial.color.setHex(nextPalette.drop)
170
+ }
171
+ effect.add(drop)
172
+
173
+ const mistRadius = dropBottomRadius * 1.2
174
+ const mistGeometry = new VisualTorusGeometry(mistRadius, Math.max(0.045, mistRadius * 0.12), 4, 16)
175
+ const mistMaterial = new THREE.MeshBasicMaterial({
176
+ color: palette.mist,
177
+ depthFunc: THREE.LessEqualDepth,
178
+ depthTest: true,
179
+ depthWrite: false,
180
+ opacity: 0.28,
181
+ side: THREE.FrontSide,
182
+ toneMapped: true,
183
+ transparent: true,
184
+ })
185
+ mistMaterial.name = 'vortex-water-mist-material'
186
+ const mist = new THREE.Mesh(mistGeometry, mistMaterial)
187
+ mist.name = `${options.pieceId}-spiral-water-mist`
188
+ mist.position.y = dropTop - dropLength * 0.92
189
+ mist.rotation.x = Math.PI / 2
190
+ mist.renderOrder = 6
191
+ markAsDecoration(mist, true)
192
+ mist.userData.applySemanticTheme = (theme: TrackThemeDefinition) => {
193
+ const nextPalette = fluidPaletteForTheme(theme.id)
194
+ mistMaterial.color.setHex(nextPalette.mist)
195
+ }
196
+ effect.add(mist)
197
+
198
+ root.add(effect)
199
+ return effect
200
+ }
201
+
202
+ export function addFlowingWaterSurfaceEffect(
203
+ root: THREE.Object3D,
204
+ options: FlowingWaterSurfaceEffectOptions,
205
+ ): THREE.Mesh {
206
+ const animationStart = nowMilliseconds()
207
+ const palette = fluidPaletteForTheme(options.themeId)
208
+ const uniforms = {
209
+ ...THREE.UniformsUtils.clone(THREE.UniformsLib.fog),
210
+ uTime: { value: 0 },
211
+ uWaveAmplitude: { value: options.waveAmplitude },
212
+ uDeepColor: { value: new THREE.Color(palette.deep) },
213
+ uShallowColor: { value: new THREE.Color(palette.shallow) },
214
+ uFoamColor: { value: new THREE.Color(palette.foam) },
215
+ uAccentColor: { value: new THREE.Color(palette.accent) },
216
+ uAccentStrength: { value: palette.accentStrength },
217
+ uOpacity: { value: options.opacity ?? 0.56 },
218
+ uOrbitDirection: { value: options.flowDirection ?? 1 },
219
+ uStartAngle: { value: options.startAngle ?? 0 },
220
+ uLaneMode: { value: options.laneMode ? 1 : 0 },
221
+ }
222
+ const material = new THREE.ShaderMaterial({
223
+ depthFunc: THREE.LessEqualDepth,
224
+ depthTest: true,
225
+ depthWrite: false,
226
+ fog: true,
227
+ fragmentShader: WATER_SURFACE_FRAGMENT_SHADER,
228
+ side: THREE.FrontSide,
229
+ toneMapped: true,
230
+ transparent: true,
231
+ uniforms,
232
+ vertexShader: WATER_SURFACE_VERTEX_SHADER,
233
+ })
234
+ const materialWithAttributeDefaults = material as THREE.ShaderMaterial & {
235
+ defaultAttributeValues?: Record<string, number[]>
236
+ }
237
+ materialWithAttributeDefaults.defaultAttributeValues = {
238
+ ...materialWithAttributeDefaults.defaultAttributeValues,
239
+ waterFlowDirection: [0, 0, -1],
240
+ waterFlowPhase: [0],
241
+ waterTurbulence: [0],
242
+ waterLaneBlend: [options.laneMode ? 1 : 0],
243
+ waterLaneLateral: [0.5],
244
+ waterWallDistance: [1],
245
+ waterWallFoamStrength: [1],
246
+ }
247
+ material.name = 'flowing-water-surface-material'
248
+ const surface = new THREE.Mesh(options.geometry, material)
249
+ surface.name = options.name ?? `${options.pieceId}-flowing-water-surface`
250
+ surface.renderOrder = 4
251
+ surface.userData.flowSpeed = (options.speed ?? 1) * WATER_ANIMATION_SPEED_SCALE
252
+ surface.userData.flowDirection = options.flowDirection ?? 1
253
+ surface.userData.fluidSurfaceStyle = options.style ?? 'water'
254
+ surface.userData.fluidPalette = palette.id
255
+ surface.userData.semanticAppearanceId = FLOWING_WATER_APPEARANCE_ID
256
+ if (options.laneMode) {
257
+ const flowPhases = options.geometry.getAttribute('waterFlowPhase')
258
+ if (flowPhases) {
259
+ const localFlowPhases = Float32Array.from(
260
+ { length: flowPhases.count },
261
+ (_unused, index) => flowPhases.getX(index),
262
+ )
263
+ const setWaterConnectionPhase = (phase: number): void => {
264
+ if (!Number.isFinite(phase)) return
265
+ const phaseOffset = phase / 1.6
266
+ for (let index = 0; index < flowPhases.count; index += 1) {
267
+ flowPhases.setX(index, localFlowPhases[index]! + phaseOffset)
268
+ }
269
+ flowPhases.needsUpdate = true
270
+ surface.userData.waterConnectionPhase = phase
271
+ }
272
+ surface.userData.setWaterConnectionPhase = setWaterConnectionPhase
273
+ setWaterConnectionPhase(options.connectionPhase ?? 0)
274
+ }
275
+ }
276
+ surface.userData.applySemanticTheme = (theme: TrackThemeDefinition) => {
277
+ const nextPalette = fluidPaletteForTheme(theme.id)
278
+ uniforms.uDeepColor.value.setHex(nextPalette.deep)
279
+ uniforms.uShallowColor.value.setHex(nextPalette.shallow)
280
+ uniforms.uFoamColor.value.setHex(nextPalette.foam)
281
+ uniforms.uAccentColor.value.setHex(nextPalette.accent)
282
+ uniforms.uAccentStrength.value = nextPalette.accentStrength
283
+ surface.userData.fluidPalette = nextPalette.id
284
+ }
285
+ surface.userData.visualFluidThickness = 0.045
286
+ surface.onBeforeRender = () => {
287
+ if (!surface.visible) return
288
+ uniforms.uTime.value =
289
+ ((nowMilliseconds() - animationStart) / 1_000) * (options.speed ?? 1) * WATER_ANIMATION_SPEED_SCALE
290
+ }
291
+ markAsDecoration(surface, true)
292
+ root.add(surface)
293
+ return surface
294
+ }
295
+
296
+ function createWaterSurfaceGeometry(options: VortexWaterEffectOptions, centerY: number): THREE.BufferGeometry {
297
+ const positions: number[] = []
298
+ const uvs: number[] = []
299
+ const entryFades: number[] = []
300
+ const laneBlends: number[] = []
301
+ const flowDirections: number[] = []
302
+ const flowPhases: number[] = []
303
+ const laneLaterals: number[] = []
304
+ const wallDistances: number[] = []
305
+ const wallFoamStrengths: number[] = []
306
+ const indices: number[] = []
307
+ const geometryMetadata: Record<string, number> = {}
308
+ const entryBoundaryNormalPairs: Array<readonly [number, number]> = []
309
+ const center = options.center instanceof THREE.Vector3 ? options.center : new THREE.Vector3(...options.center)
310
+ const entryTransition = options.entryTransition ?? []
311
+ const normalizeAngle = (angle: number): number => ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
312
+ const entryBoundaryAngles = entryTransition.map(column => {
313
+ const boundary = toLocalWaterPoint(column.boundary, center)
314
+ return normalizeAngle(Math.atan2(boundary.z, boundary.x))
315
+ })
316
+ const entryOpeningCenter = entryBoundaryAngles.length >= 2
317
+ ? normalizeAngle(
318
+ Math.atan2(
319
+ entryBoundaryAngles.reduce((sum, angle) => sum + Math.sin(angle), 0),
320
+ entryBoundaryAngles.reduce((sum, angle) => sum + Math.cos(angle), 0),
321
+ ),
322
+ )
323
+ : undefined
324
+ const signedAngleDifference = (angle: number, reference: number): number =>
325
+ Math.atan2(Math.sin(angle - reference), Math.cos(angle - reference))
326
+ const entryOpeningHalfAngle = entryOpeningCenter === undefined
327
+ ? 0
328
+ : Math.max(...entryBoundaryAngles.map(angle => Math.abs(signedAngleDifference(angle, entryOpeningCenter))))
329
+ const isEntryOpeningAngle = (angle: number): boolean =>
330
+ entryOpeningCenter !== undefined &&
331
+ Math.abs(signedAngleDifference(angle, entryOpeningCenter)) <= entryOpeningHalfAngle + 0.000_001
332
+ const entryTerminalHalfWidth =
333
+ entryTransition.length >= 2
334
+ ? toLocalWaterPoint(entryTransition[0]!.start, center).distanceTo(
335
+ toLocalWaterPoint(entryTransition.at(-1)!.start, center),
336
+ ) / 2
337
+ : 1
338
+ const surfaceAngles = [
339
+ ...Array.from(
340
+ { length: SURFACE_ANGULAR_SEGMENTS },
341
+ (_, segment) => (segment / SURFACE_ANGULAR_SEGMENTS) * Math.PI * 2,
342
+ ),
343
+ ...entryBoundaryAngles,
344
+ ]
345
+ .sort((left, right) => left - right)
346
+ .filter((angle, index, angles) => index === 0 || Math.abs(angle - angles[index - 1]!) > 0.000_001)
347
+ const closedSurfaceAngles = [...surfaceAngles, surfaceAngles[0]! + Math.PI * 2]
348
+ const rowSize = closedSurfaceAngles.length
349
+ geometryMetadata.waterBowlRowSize = rowSize
350
+ geometryMetadata.waterBowlOuterRingVertexOffset = SURFACE_RADIAL_SEGMENTS * rowSize
351
+
352
+ for (let ring = 0; ring <= SURFACE_RADIAL_SEGMENTS; ring += 1) {
353
+ const radialProgress = ring / SURFACE_RADIAL_SEGMENTS
354
+ const radius = THREE.MathUtils.lerp(options.innerRadius, options.outerRadius, radialProgress)
355
+ const y = options.surfaceHeightAtRadius(radius) - centerY + TRACK_WATER_SURFACE_OFFSET
356
+
357
+ for (const [angleIndex, angle] of closedSurfaceAngles.entries()) {
358
+ const angularProgress = angleIndex === closedSurfaceAngles.length - 1 ? 1 : normalizeAngle(angle) / (Math.PI * 2)
359
+ positions.push(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
360
+ uvs.push(radialProgress, angularProgress)
361
+ entryFades.push(1)
362
+ laneBlends.push(0)
363
+ flowDirections.push(0, 0, -1)
364
+ flowPhases.push(0)
365
+ laneLaterals.push(0.5)
366
+ const insideEntryMouth = isEntryOpeningAngle(angle) && radialProgress > 0.8
367
+ // The circular bowl rim stops at the inlet mouth. Within that opening,
368
+ // the bowl owns no wall-contact foam at all. The separate inlet rows
369
+ // carry the two real side-wall contacts up to this boundary; keeping
370
+ // them off the bowl vertices prevents a radial white wedge from being
371
+ // interpolated into the open water-to-water join.
372
+ wallDistances.push(insideEntryMouth ? 1 : (1 - radialProgress) * 3.2)
373
+ wallFoamStrengths.push(insideEntryMouth ? 0 : 0.5)
374
+ }
375
+ }
376
+
377
+ for (let ring = 0; ring < SURFACE_RADIAL_SEGMENTS; ring += 1) {
378
+ for (let segment = 0; segment < surfaceAngles.length; segment += 1) {
379
+ const inner = ring * rowSize + segment
380
+ const outer = inner + rowSize
381
+ // The water is viewed from above with FrontSide culling. Keep the bowl
382
+ // skin wound upward so it is not discarded by the renderer.
383
+ indices.push(inner, inner + 1, outer, outer, inner + 1, outer + 1)
384
+ }
385
+ }
386
+
387
+ if (entryTransition.length >= 2) {
388
+ const outerRingOffset = SURFACE_RADIAL_SEGMENTS * rowSize
389
+ const entryCenterStart = entryTransition
390
+ .map(column => toLocalWaterPoint(column.start, center))
391
+ .reduce((sum, point) => sum.add(point), new THREE.Vector3())
392
+ .multiplyScalar(1 / entryTransition.length)
393
+ const entryCenterCollar = entryTransition
394
+ .map(column => toLocalWaterPoint(column.collar, center))
395
+ .reduce((sum, point) => sum.add(point), new THREE.Vector3())
396
+ .multiplyScalar(1 / entryTransition.length)
397
+ const entryCenterBoundary = entryTransition
398
+ .map(column => toLocalWaterPoint(column.boundary, center))
399
+ .reduce((sum, point) => sum.add(point), new THREE.Vector3())
400
+ .multiplyScalar(1 / entryTransition.length)
401
+ const approachLength = entryCenterCollar.distanceTo(entryCenterStart)
402
+ const fanLength = entryCenterBoundary.distanceTo(entryCenterCollar)
403
+ const entryLength = Math.max(0.000_001, approachLength + fanLength)
404
+ const fallbackFlow = entryCenterBoundary.clone().sub(entryCenterStart).setY(0).normalize()
405
+ const normalizedHorizontalFlow = (from: THREE.Vector3, to: THREE.Vector3): THREE.Vector3 => {
406
+ const flow = to.clone().sub(from).setY(0)
407
+ return flow.lengthSq() > 0.000_001 ? flow.normalize() : fallbackFlow.clone()
408
+ }
409
+ const approachFlow = normalizedHorizontalFlow(entryCenterStart, entryCenterCollar)
410
+ const fanFlow = normalizedHorizontalFlow(entryCenterCollar, entryCenterBoundary)
411
+ const collarFlow = approachFlow.clone().add(fanFlow)
412
+ if (collarFlow.lengthSq() > 0.000_001) collarFlow.normalize()
413
+ else collarFlow.copy(fallbackFlow)
414
+ const entryStartVertexOffset = positions.length / 3
415
+ const sections = entryTransition.map((column, columnIndex) => {
416
+ const start = toLocalWaterPoint(column.start, center)
417
+ const collar = toLocalWaterPoint(column.collar, center)
418
+ const boundaryAngle = entryBoundaryAngles[columnIndex]!
419
+ const boundaryAngleIndex = surfaceAngles.findIndex(
420
+ angle => Math.abs(Math.atan2(Math.sin(angle - boundaryAngle), Math.cos(angle - boundaryAngle))) < 0.000_001,
421
+ )
422
+ if (boundaryAngleIndex < 0) throw new Error('Missing shared water inlet boundary angle.')
423
+ const boundaryIndex = outerRingOffset + boundaryAngleIndex
424
+ // The structural entry boundary and circular water ring are generated
425
+ // through separate paths. Reuse the bowl vertex position verbatim so
426
+ // tiny analytic/tessellation differences cannot overlap or crack the
427
+ // water at the mouth.
428
+ const boundary = new THREE.Vector3(
429
+ positions[boundaryIndex * 3]!,
430
+ positions[boundaryIndex * 3 + 1]! - TRACK_WATER_SURFACE_OFFSET,
431
+ positions[boundaryIndex * 3 + 2]!,
432
+ )
433
+ // Keep lane advection on the bowl's coincident mouth vertex, then
434
+ // interpolate it across the first radial bowl band. The inlet receives
435
+ // its own same-position boundary vertex below so its side-wall foam can
436
+ // terminate here without leaking into the bowl.
437
+ laneBlends[boundaryIndex] = 1
438
+ flowDirections[boundaryIndex * 3] = fanFlow.x
439
+ flowDirections[boundaryIndex * 3 + 1] = fanFlow.y
440
+ flowDirections[boundaryIndex * 3 + 2] = fanFlow.z
441
+ flowPhases[boundaryIndex] = entryLength / 1.6
442
+ laneLaterals[boundaryIndex] = columnIndex / (entryTransition.length - 1)
443
+ const inletWallDistance = 1 - Math.abs((columnIndex / (entryTransition.length - 1)) * 2 - 1)
444
+ return { boundary, boundaryIndex, collar, inletWallDistance, start }
445
+ })
446
+ const appendEntryVertex = (
447
+ point: THREE.Vector3,
448
+ longitudinalProgress: number,
449
+ lateralProgress: number,
450
+ flow: THREE.Vector3,
451
+ flowPhase: number,
452
+ entryFade: number,
453
+ inletWallDistance: number,
454
+ ): number => {
455
+ const index = positions.length / 3
456
+ positions.push(point.x, point.y + TRACK_WATER_SURFACE_OFFSET, point.z)
457
+ uvs.push(longitudinalProgress, lateralProgress)
458
+ entryFades.push(entryFade)
459
+ laneBlends.push(1)
460
+ flowDirections.push(flow.x, flow.y, flow.z)
461
+ flowPhases.push(flowPhase)
462
+ laneLaterals.push(lateralProgress)
463
+ wallDistances.push(inletWallDistance)
464
+ wallFoamStrengths.push(1)
465
+ return index
466
+ }
467
+ const startIndices = sections.map((section, columnIndex) =>
468
+ appendEntryVertex(
469
+ section.start,
470
+ 0,
471
+ columnIndex / (sections.length - 1),
472
+ approachFlow,
473
+ 0,
474
+ options.entryConnected ? 1 : 0,
475
+ section.inletWallDistance,
476
+ ),
477
+ )
478
+ const entryCollarVertexOffset = positions.length / 3
479
+ const collarProgress = approachLength / entryLength
480
+ const collarIndices = sections.map((section, columnIndex) =>
481
+ appendEntryVertex(
482
+ section.collar,
483
+ collarProgress,
484
+ columnIndex / (sections.length - 1),
485
+ collarFlow,
486
+ approachLength / 1.6,
487
+ options.entryConnected ? 1 : collarProgress,
488
+ section.inletWallDistance,
489
+ ),
490
+ )
491
+ const entryBoundaryVertexOffset = positions.length / 3
492
+ const boundaryIndices = sections.map((section, columnIndex) =>
493
+ appendEntryVertex(
494
+ section.boundary,
495
+ 1,
496
+ columnIndex / (sections.length - 1),
497
+ fanFlow,
498
+ entryLength / 1.6,
499
+ 1,
500
+ section.inletWallDistance,
501
+ ),
502
+ )
503
+ boundaryIndices.forEach((boundaryIndex, columnIndex) => {
504
+ entryBoundaryNormalPairs.push([boundaryIndex, sections[columnIndex]!.boundaryIndex])
505
+ })
506
+ for (let column = 0; column < sections.length - 1; column += 1) {
507
+ const start = startIndices[column]!
508
+ const nextStart = startIndices[column + 1]!
509
+ const collar = collarIndices[column]!
510
+ const nextCollar = collarIndices[column + 1]!
511
+ const boundary = boundaryIndices[column]!
512
+ const nextBoundary = boundaryIndices[column + 1]!
513
+ indices.push(start, nextStart, collar, nextStart, nextCollar, collar)
514
+ indices.push(collar, nextCollar, boundary, nextCollar, nextBoundary, boundary)
515
+ }
516
+ geometryMetadata.waterEntryStartVertexOffset = entryStartVertexOffset
517
+ geometryMetadata.waterEntryCollarVertexOffset = entryCollarVertexOffset
518
+ geometryMetadata.waterEntryBoundaryVertexOffset = entryBoundaryVertexOffset
519
+ geometryMetadata.waterEntryColumnCount = sections.length
520
+ geometryMetadata.waterEntryRowCount = 3
521
+ }
522
+
523
+ const centerFunnel = options.centerFunnel ?? []
524
+ const funnelOffset = positions.length / 3
525
+ if (centerFunnel.length >= 2) {
526
+ for (const [ringIndex, ring] of centerFunnel.entries()) {
527
+ const funnelProgress = ringIndex / (centerFunnel.length - 1)
528
+ for (const [angleIndex, angle] of closedSurfaceAngles.entries()) {
529
+ const angularProgress =
530
+ angleIndex === closedSurfaceAngles.length - 1 ? 1 : normalizeAngle(angle) / (Math.PI * 2)
531
+ positions.push(
532
+ Math.cos(angle) * ring.radius,
533
+ ring.height - centerY + TRACK_WATER_SURFACE_OFFSET,
534
+ Math.sin(angle) * ring.radius,
535
+ )
536
+ uvs.push((1 - funnelProgress) * 0.18, angularProgress)
537
+ entryFades.push(1)
538
+ laneBlends.push(0)
539
+ flowDirections.push(0, 0, -1)
540
+ flowPhases.push(0)
541
+ laneLaterals.push(0.5)
542
+ wallDistances.push(1)
543
+ wallFoamStrengths.push(0)
544
+ }
545
+ }
546
+ for (let ring = 0; ring < centerFunnel.length - 1; ring += 1) {
547
+ for (let segment = 0; segment < surfaceAngles.length; segment += 1) {
548
+ const inner = funnelOffset + ring * rowSize + segment
549
+ const outer = inner + rowSize
550
+ indices.push(inner, inner + 1, outer, outer, inner + 1, outer + 1)
551
+ }
552
+ }
553
+ }
554
+
555
+ const geometry = new THREE.BufferGeometry()
556
+ geometry.name = 'vortex-water-surface-geometry'
557
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
558
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
559
+ geometry.setAttribute('waterEntryFade', new THREE.Float32BufferAttribute(entryFades, 1))
560
+ geometry.setAttribute('waterLaneBlend', new THREE.Float32BufferAttribute(laneBlends, 1))
561
+ geometry.setAttribute('waterFlowDirection', new THREE.Float32BufferAttribute(flowDirections, 3))
562
+ geometry.setAttribute('waterFlowPhase', new THREE.Float32BufferAttribute(flowPhases, 1))
563
+ geometry.setAttribute('waterLaneLateral', new THREE.Float32BufferAttribute(laneLaterals, 1))
564
+ geometry.setAttribute('waterWallDistance', new THREE.Float32BufferAttribute(wallDistances, 1))
565
+ geometry.setAttribute('waterWallFoamStrength', new THREE.Float32BufferAttribute(wallFoamStrengths, 1))
566
+ geometry.setIndex(indices)
567
+ Object.assign(geometry.userData, geometryMetadata)
568
+ geometry.userData.waterEntryWallDistanceBasis = 'topology-owned-inlet-rows-with-open-bowl-mouth'
569
+ geometry.userData.waterEntryTerminalHalfWidth = entryTerminalHalfWidth
570
+ geometry.computeVertexNormals()
571
+ const normals = geometry.getAttribute('normal')
572
+ const seamRingOffsets = [
573
+ ...Array.from({ length: SURFACE_RADIAL_SEGMENTS + 1 }, (_, ring) => ring * rowSize),
574
+ ...(centerFunnel.length >= 2
575
+ ? Array.from({ length: centerFunnel.length }, (_, ring) => funnelOffset + ring * rowSize)
576
+ : []),
577
+ ]
578
+ for (const offset of seamRingOffsets) {
579
+ const first = new THREE.Vector3().fromBufferAttribute(normals, offset)
580
+ const last = new THREE.Vector3().fromBufferAttribute(normals, offset + rowSize - 1)
581
+ const shared = first.add(last).normalize()
582
+ normals.setXYZ(offset, shared.x, shared.y, shared.z)
583
+ normals.setXYZ(offset + rowSize - 1, shared.x, shared.y, shared.z)
584
+ }
585
+ for (const [entryBoundaryIndex, bowlBoundaryIndex] of entryBoundaryNormalPairs) {
586
+ const shared = new THREE.Vector3()
587
+ .fromBufferAttribute(normals, entryBoundaryIndex)
588
+ .add(new THREE.Vector3().fromBufferAttribute(normals, bowlBoundaryIndex))
589
+ .normalize()
590
+ normals.setXYZ(entryBoundaryIndex, shared.x, shared.y, shared.z)
591
+ normals.setXYZ(bowlBoundaryIndex, shared.x, shared.y, shared.z)
592
+ }
593
+ normals.needsUpdate = true
594
+ geometry.computeBoundingBox()
595
+ geometry.computeBoundingSphere()
596
+ return geometry
597
+ }
598
+
599
+ function toLocalWaterPoint(
600
+ point: THREE.Vector3 | readonly [number, number, number],
601
+ center: THREE.Vector3,
602
+ ): THREE.Vector3 {
603
+ return (point instanceof THREE.Vector3 ? point.clone() : new THREE.Vector3(...point)).sub(center)
604
+ }
605
+
606
+ function markAsDecoration(object: THREE.Object3D, ownsMaterial = false): void {
607
+ object.userData.decorative = true
608
+ object.userData.visualOnly = true
609
+ if (ownsMaterial) object.userData.ownsMaterial = true
610
+ }
611
+
612
+ function assertVortexWaterEffectOptions(options: VortexWaterEffectOptions): void {
613
+ if (!options.pieceId) throw new Error('Vortex water effect requires a pieceId.')
614
+ if (!Number.isFinite(options.innerRadius) || options.innerRadius <= 0) {
615
+ throw new Error('Vortex water effect innerRadius must be positive.')
616
+ }
617
+ if (!Number.isFinite(options.outerRadius) || options.outerRadius <= options.innerRadius) {
618
+ throw new Error('Vortex water effect outerRadius must be greater than innerRadius.')
619
+ }
620
+ if (options.dropLength !== undefined && (!Number.isFinite(options.dropLength) || options.dropLength <= 0)) {
621
+ throw new Error('Vortex water effect dropLength must be positive when provided.')
622
+ }
623
+ }
624
+
625
+ function nowMilliseconds(): number {
626
+ return typeof performance === 'undefined' ? Date.now() : performance.now()
627
+ }
628
+
629
+ const WATER_SURFACE_VERTEX_SHADER = /* glsl */ `
630
+ uniform float uTime;
631
+ uniform float uWaveAmplitude;
632
+ uniform float uOrbitDirection;
633
+ uniform float uStartAngle;
634
+ uniform float uLaneMode;
635
+ attribute float waterEntryFade;
636
+ attribute float waterLaneBlend;
637
+ attribute float waterLaneLateral;
638
+ attribute vec3 waterFlowDirection;
639
+ attribute float waterFlowPhase;
640
+ attribute float waterTurbulence;
641
+ attribute float waterWallDistance;
642
+ attribute float waterWallFoamStrength;
643
+
644
+ varying float vRadialProgress;
645
+ varying float vAngularProgress;
646
+ varying float vEntryFade;
647
+ varying float vLaneBlend;
648
+ varying vec3 vWaterWorldPosition;
649
+ varying vec3 vWaterWorldNormal;
650
+ varying vec3 vWaterWorldFlow;
651
+ varying float vWaterFlowPhase;
652
+ varying float vTurbulence;
653
+ varying float vWaterWallDistance;
654
+ varying float vWaterWallFoamStrength;
655
+
656
+ #include <fog_pars_vertex>
657
+
658
+ void main() {
659
+ float radialProgress = clamp(uv.x, 0.0, 1.0);
660
+ float angle = uv.y * 6.28318530718 + uStartAngle;
661
+ float directedAngle = angle * uOrbitDirection;
662
+ float laneBlend = max(step(0.5, uLaneMode), waterLaneBlend);
663
+
664
+ vec3 vortexFlow = normalize(vec3(-position.z, 0.0, position.x) * uOrbitDirection + vec3(0.000001));
665
+ vec3 localFlow = normalize(mix(vortexFlow, waterFlowDirection, laneBlend));
666
+ vec3 worldFlow = normalize(mat3(modelMatrix) * localFlow);
667
+ vec3 worldNormal = normalize(mat3(modelMatrix) * normal);
668
+
669
+ vec3 displaced = position;
670
+
671
+ vec3 waterWorldPosition = (modelMatrix * vec4(displaced, 1.0)).xyz;
672
+ vRadialProgress = radialProgress;
673
+ vAngularProgress = mix(uv.y, waterLaneLateral, laneBlend);
674
+ vEntryFade = waterEntryFade;
675
+ vLaneBlend = laneBlend;
676
+ vWaterWorldPosition = waterWorldPosition;
677
+ vWaterWorldNormal = worldNormal;
678
+ vWaterWorldFlow = worldFlow;
679
+ vWaterFlowPhase = waterFlowPhase;
680
+ vTurbulence = waterTurbulence;
681
+ vWaterWallDistance = waterWallDistance;
682
+ vWaterWallFoamStrength = waterWallFoamStrength;
683
+ vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0);
684
+ gl_Position = projectionMatrix * mvPosition;
685
+ #include <fog_vertex>
686
+ }
687
+ `
688
+
689
+ const WATER_SURFACE_FRAGMENT_SHADER = /* glsl */ `
690
+ uniform vec3 uDeepColor;
691
+ uniform vec3 uShallowColor;
692
+ uniform vec3 uFoamColor;
693
+ uniform vec3 uAccentColor;
694
+ uniform float uOpacity;
695
+ uniform float uAccentStrength;
696
+ uniform float uTime;
697
+ uniform float uOrbitDirection;
698
+ uniform float uStartAngle;
699
+ uniform float uLaneMode;
700
+
701
+ varying float vRadialProgress;
702
+ varying float vAngularProgress;
703
+ varying float vEntryFade;
704
+ varying float vLaneBlend;
705
+ varying vec3 vWaterWorldPosition;
706
+ varying vec3 vWaterWorldNormal;
707
+ varying vec3 vWaterWorldFlow;
708
+ varying float vWaterFlowPhase;
709
+ varying float vTurbulence;
710
+ varying float vWaterWallDistance;
711
+ varying float vWaterWallFoamStrength;
712
+
713
+ #include <fog_pars_fragment>
714
+
715
+ float waterHash(vec2 point) {
716
+ return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453123);
717
+ }
718
+
719
+ float waterNoise(vec2 point) {
720
+ vec2 cell = floor(point);
721
+ vec2 fraction = fract(point);
722
+ fraction = fraction * fraction * (3.0 - 2.0 * fraction);
723
+ float a = waterHash(cell);
724
+ float b = waterHash(cell + vec2(1.0, 0.0));
725
+ float c = waterHash(cell + vec2(0.0, 1.0));
726
+ float d = waterHash(cell + vec2(1.0, 1.0));
727
+ return mix(mix(a, b, fraction.x), mix(c, d, fraction.x), fraction.y);
728
+ }
729
+
730
+ float waterFbm(vec2 point) {
731
+ float value = 0.0;
732
+ float amplitude = 0.54;
733
+ for (int octave = 0; octave < 4; octave += 1) {
734
+ value += waterNoise(point) * amplitude;
735
+ point = mat2(1.72, 1.21, -1.21, 1.72) * point + 1.37;
736
+ amplitude *= 0.48;
737
+ }
738
+ return value;
739
+ }
740
+
741
+ void main() {
742
+ float laneBlend = vLaneBlend;
743
+ float angle = vAngularProgress * 6.28318530718 + uStartAngle;
744
+ float directedAngle = angle * uOrbitDirection;
745
+ vec2 vortexOrbit = vec2(cos(directedAngle), sin(directedAngle));
746
+
747
+ float lanePhase = vWaterFlowPhase * 6.28318530718;
748
+ float vortexPhase = directedAngle * 2.0 - vRadialProgress * 17.0;
749
+ float flowPhase = mix(vortexPhase, lanePhase, laneBlend);
750
+ float lateral = vAngularProgress * 2.0 - 1.0;
751
+ float broadPhase = mix(
752
+ directedAngle * 2.0 - vRadialProgress * 17.0 - uTime * 1.15 + vortexOrbit.y * 1.8,
753
+ flowPhase - uTime * 1.15 + lateral * 1.8,
754
+ laneBlend
755
+ );
756
+ float finePhase = mix(
757
+ directedAngle * 5.0 - vRadialProgress * 39.0 - uTime * 2.18 - vortexOrbit.x * 6.4,
758
+ flowPhase * 2.35 - uTime * 2.18 - lateral * 6.4,
759
+ laneBlend
760
+ );
761
+ float crossPhase = mix(
762
+ directedAngle * 3.0 - vRadialProgress * 12.0 - uTime * 0.82 + vortexOrbit.y * 5.8,
763
+ flowPhase * 0.72 - uTime * 0.82 + lateral * 11.6,
764
+ laneBlend
765
+ );
766
+ float flowSlope = cos(broadPhase) * 0.022 + cos(finePhase) * 0.034 + cos(crossPhase) * 0.018;
767
+ float sideSlope = cos(broadPhase) * 0.018 - cos(finePhase) * 0.036 + cos(crossPhase) * 0.042;
768
+
769
+ vec3 baseNormal = normalize(vWaterWorldNormal);
770
+ vec3 flowTangent = normalize(vWaterWorldFlow - baseNormal * dot(vWaterWorldFlow, baseNormal));
771
+ vec3 lateralTangent = normalize(cross(baseNormal, flowTangent));
772
+ vec3 waterNormal = normalize(baseNormal - flowTangent * flowSlope - lateralTangent * sideSlope);
773
+ vec3 viewDirection = normalize(cameraPosition - vWaterWorldPosition);
774
+ float fresnel = pow(1.0 - clamp(dot(waterNormal, viewDirection), 0.0, 1.0), 3.2);
775
+ vec3 keyDirection = normalize(vec3(-0.34, 0.87, 0.35));
776
+ float diffuseLight = max(dot(waterNormal, keyDirection), 0.0);
777
+ vec3 halfDirection = normalize(keyDirection + viewDirection);
778
+ float specular = pow(max(dot(waterNormal, halfDirection), 0.0), 96.0);
779
+
780
+ // Everything below is expressed in accumulated path space. Two differently
781
+ // advected ripple families create a flowing, broken interference mesh without
782
+ // resetting or fanning radially when the lane curves.
783
+ vec2 vortexCurrentCoordinates =
784
+ vortexOrbit * (2.4 + vRadialProgress * 1.8) + vec2(-uTime * 0.36, uTime * 0.055);
785
+ vec2 laneCurrentCoordinates = vec2(flowPhase * 0.24 - uTime * 0.36, lateral * 4.8 + uTime * 0.055);
786
+ vec2 currentCoordinates = mix(vortexCurrentCoordinates, laneCurrentCoordinates, laneBlend);
787
+ float bodyNoise = waterFbm(currentCoordinates * 0.72);
788
+ float currentWarp = (bodyNoise - 0.5) * 2.0;
789
+ float shallowMix = mix(smoothstep(0.04, 1.0, vRadialProgress), 0.48 + bodyNoise * 0.16, laneBlend);
790
+ vec3 waterColor = mix(uDeepColor, uShallowColor, clamp(shallowMix, 0.0, 1.0));
791
+ waterColor *= 0.66 + diffuseLight * 0.24;
792
+ waterColor = mix(waterColor, vec3(0.34, 0.61, 0.58), fresnel * 0.34);
793
+ waterColor += vec3(1.0, 0.88, 0.7) * specular * 0.12;
794
+
795
+ float rippleFamilyA = sin(mix(
796
+ directedAngle * 7.0 - vRadialProgress * 55.0 - uTime * 2.35 + vortexOrbit.y * 2.8 + currentWarp * 2.8,
797
+ flowPhase * 3.25 - uTime * 2.35 + lateral * 9.4 + currentWarp * 2.8,
798
+ laneBlend
799
+ ));
800
+ float rippleFamilyB = sin(mix(
801
+ directedAngle * 5.0 - vRadialProgress * 36.0 - uTime * 1.46 - vortexOrbit.x * 3.6 - currentWarp * 3.6,
802
+ flowPhase * 2.1 - uTime * 1.46 - lateral * 15.6 - currentWarp * 3.6,
803
+ laneBlend
804
+ ));
805
+ float rippleFamilyC = sin(mix(
806
+ directedAngle * 9.0 - vRadialProgress * 72.0 - uTime * 2.9 + vortexOrbit.y * 3.1 +
807
+ sin(directedAngle * 4.0 + uTime * 0.52),
808
+ flowPhase * 4.6 - uTime * 2.9 + lateral * 3.1 + sin(lateral * 18.0 + uTime * 0.52),
809
+ laneBlend
810
+ ));
811
+ float rippleMesh = max(1.0 - abs(rippleFamilyA), 1.0 - abs(rippleFamilyB));
812
+ rippleMesh = max(rippleMesh, (1.0 - abs(rippleFamilyC)) * 0.78);
813
+ vec2 rippleBreakupCoordinates = mix(
814
+ vortexOrbit * (3.7 + vRadialProgress * 2.1) + vec2(-uTime * 0.14, uTime * 0.09),
815
+ vec2(flowPhase * 0.42 - uTime * 0.14, lateral * 7.6 + uTime * 0.09),
816
+ laneBlend
817
+ );
818
+ float rippleBreakup = waterFbm(rippleBreakupCoordinates);
819
+ float rippleHighlight = pow(clamp(rippleMesh, 0.0, 1.0), 2.7) * smoothstep(0.28, 0.82, rippleBreakup);
820
+ float softCurrent = smoothstep(0.54, 0.96, 0.5 + 0.5 * rippleFamilyA) * (0.35 + bodyNoise * 0.65);
821
+ float refractiveShimmer =
822
+ (0.5 + 0.5 * rippleFamilyA) * (0.5 + 0.5 * rippleFamilyB) * (0.55 + bodyNoise * 0.45);
823
+ waterColor += vec3(0.32, 0.52, 0.48) * rippleHighlight * 0.18;
824
+ waterColor += vec3(0.2, 0.4, 0.37) * refractiveShimmer * 0.09;
825
+ waterColor += vec3(0.15, 0.31, 0.29) * softCurrent * 0.035;
826
+ float accentRibbon = rippleHighlight * (0.72 + 0.28 * smoothstep(0.52, 0.86, rippleBreakup));
827
+ waterColor = mix(waterColor, uAccentColor, clamp(accentRibbon * uAccentStrength * 0.78, 0.0, 0.78));
828
+ waterColor += uAccentColor * accentRibbon * uAccentStrength * 0.12;
829
+
830
+ vec2 foamCoordinates = mix(
831
+ vortexOrbit * (4.8 + vRadialProgress * 1.6) + vec2(-uTime * 0.22, 0.0),
832
+ vec2(vWaterFlowPhase * 4.8 - uTime * 0.22, vAngularProgress * 12.0),
833
+ laneBlend
834
+ );
835
+ float foamNoise = waterFbm(foamCoordinates + bodyNoise * 1.3);
836
+ float wallDistance = max(0.0, vWaterWallDistance);
837
+ float edgeBand = exp(-pow((wallDistance - 0.045) * 13.0, 2.0));
838
+ float edgeBreakup = smoothstep(0.54, 0.82, foamNoise + sin(flowPhase * 0.7) * 0.08);
839
+ float edgeFoam = edgeBand * (0.48 + edgeBreakup * 0.52);
840
+ float bendFoam = vTurbulence * edgeBand * (0.42 + smoothstep(0.6, 0.86, foamNoise) * 0.45);
841
+ float foam = max(edgeFoam, bendFoam) * clamp(vWaterWallFoamStrength, 0.0, 1.0);
842
+
843
+ float alpha = clamp(uOpacity + fresnel * 0.15 + foam * 0.18 + rippleHighlight * 0.035, 0.0, 0.77);
844
+ waterColor = mix(waterColor, uFoamColor, foam * 0.78);
845
+
846
+ float entryMask = clamp(vEntryFade, 0.0, 1.0);
847
+ waterColor = mix(uShallowColor * 1.08, waterColor, 0.32 + entryMask * 0.68);
848
+ gl_FragColor = vec4(waterColor, alpha * entryMask);
849
+ #include <tonemapping_fragment>
850
+ #include <colorspace_fragment>
851
+
852
+ #ifdef USE_FOG
853
+ #ifdef FOG_EXP2
854
+ float waterFogFactor = 1.0 - exp(-fogDensity * fogDensity * vFogDepth * vFogDepth);
855
+ #else
856
+ float waterFogFactor = smoothstep(fogNear, fogFar, vFogDepth);
857
+ #endif
858
+ // Water follows scene fog but keeps a faint readable body at maximum
859
+ // distance instead of becoming indistinguishable from the background.
860
+ gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, waterFogFactor * 0.84);
861
+ #endif
862
+ }
863
+ `