@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.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- 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
|
+
`
|