@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,1952 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
MaterialPatternRecipe,
|
|
5
|
+
MaterialRecipe,
|
|
6
|
+
ThemeMaterialSlot,
|
|
7
|
+
TrackAppearanceCanopyStyle,
|
|
8
|
+
TrackAppearanceDetailStyle,
|
|
9
|
+
TrackAppearancePreset,
|
|
10
|
+
TrackAppearanceSupportStyle,
|
|
11
|
+
TrackAppearanceSurface,
|
|
12
|
+
TrackThemeDefinition,
|
|
13
|
+
} from './track-theme'
|
|
14
|
+
import {
|
|
15
|
+
createThemeMaterialTexture,
|
|
16
|
+
releaseThemeMaterialTexture,
|
|
17
|
+
themeMaterialRecipeUrls,
|
|
18
|
+
} from './theme-material-assets'
|
|
19
|
+
import { resolvedFloorMaterialIdForTheme } from './railroad-appearance'
|
|
20
|
+
|
|
21
|
+
interface MaterialBinding {
|
|
22
|
+
mesh: THREE.Mesh
|
|
23
|
+
slot: ThemeMaterialSlot
|
|
24
|
+
materialIndex?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AppearanceMaterialBinding {
|
|
28
|
+
mesh: THREE.Mesh
|
|
29
|
+
presetId: string
|
|
30
|
+
fallbackSlot: ThemeMaterialSlot
|
|
31
|
+
materialIndex?: number
|
|
32
|
+
resolveFloorByTheme?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ThemeVisualBinding = THREE.Object3D & {
|
|
36
|
+
userData: {
|
|
37
|
+
applySemanticTheme?: (theme: TrackThemeDefinition) => void
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ThemeMaterialRegistry {
|
|
42
|
+
private readonly materials = new Map<ThemeMaterialSlot, THREE.Material>()
|
|
43
|
+
private readonly appearanceMaterials = new Map<string, THREE.Material>()
|
|
44
|
+
private readonly bindings = new Set<MaterialBinding>()
|
|
45
|
+
private readonly appearanceBindings = new Set<AppearanceMaterialBinding>()
|
|
46
|
+
private readonly bindingIndex = new WeakMap<THREE.Mesh, Map<number, MaterialBinding>>()
|
|
47
|
+
private readonly appearanceBindingIndex = new WeakMap<THREE.Mesh, Map<number, AppearanceMaterialBinding>>()
|
|
48
|
+
private readonly visibilityBindings = new Set<THREE.Object3D>()
|
|
49
|
+
private readonly semanticThemeBindings = new Set<ThemeVisualBinding>()
|
|
50
|
+
private disposed = false
|
|
51
|
+
|
|
52
|
+
constructor(private theme: TrackThemeDefinition) {}
|
|
53
|
+
|
|
54
|
+
get activeTheme(): TrackThemeDefinition {
|
|
55
|
+
return this.theme
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get(slot: ThemeMaterialSlot): THREE.Material {
|
|
59
|
+
this.assertActive()
|
|
60
|
+
const current = this.materials.get(slot)
|
|
61
|
+
if (current) return current
|
|
62
|
+
const recipe = this.theme.materials[slot]
|
|
63
|
+
if (!recipe) throw new Error(`Theme "${this.theme.id}" does not define material slot "${slot}".`)
|
|
64
|
+
const material = createMaterial(slot, recipe)
|
|
65
|
+
this.materials.set(slot, material)
|
|
66
|
+
return material
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
requestedAssetUrls(theme: TrackThemeDefinition = this.theme): readonly string[] {
|
|
70
|
+
const urls = new Set<string>()
|
|
71
|
+
for (const slot of this.materials.keys()) {
|
|
72
|
+
themeMaterialRecipeUrls(theme.materials[slot]).forEach(url => urls.add(url))
|
|
73
|
+
}
|
|
74
|
+
for (const key of this.appearanceMaterials.keys()) {
|
|
75
|
+
const appearance = parseAppearanceMaterialKey(key)
|
|
76
|
+
const recipe = findAppearanceRecipe(theme, appearance.presetId) ?? theme.materials[appearance.fallbackSlot]
|
|
77
|
+
themeMaterialRecipeUrls(recipe).forEach(url => urls.add(url))
|
|
78
|
+
}
|
|
79
|
+
return [...urls]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get requestedTextures(): readonly THREE.Texture[] {
|
|
83
|
+
const textures = new Set<THREE.Texture>()
|
|
84
|
+
this.materials.forEach(material => collectTextures(material, textures))
|
|
85
|
+
this.appearanceMaterials.forEach(material => collectTextures(material, textures))
|
|
86
|
+
return [...textures]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async ready(): Promise<void> {
|
|
90
|
+
await Promise.all(
|
|
91
|
+
this.requestedTextures.map(texture =>
|
|
92
|
+
Promise.resolve(texture.userData.themeAssetReady as Promise<void> | undefined),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async preUpload(renderer: THREE.WebGLRenderer, sliceMilliseconds = 4): Promise<void> {
|
|
98
|
+
await this.ready()
|
|
99
|
+
let sliceStartedAt = performance.now()
|
|
100
|
+
for (const texture of this.requestedTextures) {
|
|
101
|
+
renderer.initTexture(texture)
|
|
102
|
+
if (performance.now() - sliceStartedAt < sliceMilliseconds) continue
|
|
103
|
+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
|
104
|
+
sliceStartedAt = performance.now()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getAppearance(presetId: string | undefined, fallbackSlot: ThemeMaterialSlot): THREE.Material {
|
|
109
|
+
this.assertActive()
|
|
110
|
+
if (!presetId) return this.get(fallbackSlot)
|
|
111
|
+
const recipe = findAppearanceRecipe(this.theme, presetId)
|
|
112
|
+
const key = appearanceMaterialKey(presetId, fallbackSlot)
|
|
113
|
+
const current = this.appearanceMaterials.get(key)
|
|
114
|
+
if (current) return current
|
|
115
|
+
const material = createAppearanceMaterial(
|
|
116
|
+
presetId,
|
|
117
|
+
fallbackSlot,
|
|
118
|
+
recipe ?? this.theme.materials[fallbackSlot],
|
|
119
|
+
findAppearancePreset(this.theme, presetId)?.canopyStyle,
|
|
120
|
+
findAppearancePreset(this.theme, presetId)?.supportStyle,
|
|
121
|
+
)
|
|
122
|
+
this.appearanceMaterials.set(key, material)
|
|
123
|
+
return material
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getAppearanceRecipe(presetId: string | undefined, fallbackSlot: ThemeMaterialSlot): MaterialRecipe {
|
|
127
|
+
this.assertActive()
|
|
128
|
+
return (presetId ? findAppearanceRecipe(this.theme, presetId) : undefined) ?? this.theme.materials[fallbackSlot]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getAppearancePresets(surface: TrackAppearanceSurface): readonly TrackAppearancePreset[] {
|
|
132
|
+
return this.theme.appearancePresets?.[surface] ?? []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getAppearanceDetailStyle(presetId: string | undefined): TrackAppearanceDetailStyle | undefined {
|
|
136
|
+
return presetId ? findAppearancePreset(this.theme, presetId)?.detailStyle : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getAppearanceCanopyStyle(presetId: string | undefined): TrackAppearanceCanopyStyle | undefined {
|
|
140
|
+
return presetId ? findAppearancePreset(this.theme, presetId)?.canopyStyle : undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
bind(mesh: THREE.Mesh, slot: ThemeMaterialSlot, materialIndex?: number): void {
|
|
144
|
+
this.assertActive()
|
|
145
|
+
assignMaterial(mesh, this.get(slot), materialIndex)
|
|
146
|
+
applyHolographicFloorShadowPolicy(mesh)
|
|
147
|
+
mesh.userData.materialSlot = slot
|
|
148
|
+
applyThemeVisibility(mesh, this.theme.id)
|
|
149
|
+
if (hasThemeVisibility(mesh)) this.visibilityBindings.add(mesh)
|
|
150
|
+
this.rememberBinding({ mesh, slot, materialIndex })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
bindAppearance(
|
|
154
|
+
mesh: THREE.Mesh,
|
|
155
|
+
presetId: string | undefined,
|
|
156
|
+
fallbackSlot: ThemeMaterialSlot,
|
|
157
|
+
materialIndex?: number,
|
|
158
|
+
): void {
|
|
159
|
+
if (!presetId) {
|
|
160
|
+
this.bind(mesh, fallbackSlot, materialIndex)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
this.assertActive()
|
|
164
|
+
const appearanceMaterial = this.getAppearance(presetId, fallbackSlot)
|
|
165
|
+
if (appearanceMaterial === this.get(fallbackSlot)) {
|
|
166
|
+
this.bind(mesh, fallbackSlot, materialIndex)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
assignMaterial(mesh, appearanceMaterial, materialIndex)
|
|
170
|
+
applyHolographicFloorShadowPolicy(mesh)
|
|
171
|
+
mesh.userData.appearanceMaterialId = presetId
|
|
172
|
+
// Appearance bindings still occupy a semantic fallback slot. Keeping the
|
|
173
|
+
// slot on the mesh makes editor selection, diagnostics, and material QA
|
|
174
|
+
// independent of whether a theme supplies a dedicated preset recipe.
|
|
175
|
+
mesh.userData.materialSlot = fallbackSlot
|
|
176
|
+
applyThemeVisibility(mesh, this.theme.id)
|
|
177
|
+
if (hasThemeVisibility(mesh)) this.visibilityBindings.add(mesh)
|
|
178
|
+
this.rememberAppearanceBinding({ mesh, presetId, fallbackSlot, materialIndex })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
captureBindings(root: THREE.Object3D): void {
|
|
182
|
+
this.assertActive()
|
|
183
|
+
const slotsByMaterial = new Map([...this.materials.entries()].map(([slot, material]) => [material, slot]))
|
|
184
|
+
const appearancesByMaterial = new Map(
|
|
185
|
+
[...this.appearanceMaterials.entries()].map(([key, material]) => [material, parseAppearanceMaterialKey(key)]),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
root.traverse(object => {
|
|
189
|
+
if (typeof object.userData.applySemanticTheme === 'function') {
|
|
190
|
+
this.semanticThemeBindings.add(object as ThemeVisualBinding)
|
|
191
|
+
}
|
|
192
|
+
applyThemeVisibility(object, this.theme.id)
|
|
193
|
+
if (hasThemeVisibility(object)) this.visibilityBindings.add(object)
|
|
194
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
195
|
+
const meshMaterials = Array.isArray(object.material) ? object.material : [object.material]
|
|
196
|
+
meshMaterials.forEach((material, materialIndex) => {
|
|
197
|
+
const appearance = appearancesByMaterial.get(material)
|
|
198
|
+
if (appearance) {
|
|
199
|
+
object.userData.appearanceMaterialId ??= appearance.presetId
|
|
200
|
+
object.userData.materialSlot ??= appearance.fallbackSlot
|
|
201
|
+
this.rememberAppearanceBinding({
|
|
202
|
+
mesh: object,
|
|
203
|
+
...appearance,
|
|
204
|
+
...(Array.isArray(object.material) ? { materialIndex } : {}),
|
|
205
|
+
})
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
const slot = slotsByMaterial.get(material)
|
|
209
|
+
if (!slot) return
|
|
210
|
+
object.userData.materialSlot ??= slot
|
|
211
|
+
this.rememberBinding({
|
|
212
|
+
mesh: object,
|
|
213
|
+
slot,
|
|
214
|
+
...(Array.isArray(object.material) ? { materialIndex } : {}),
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
applyHolographicFloorShadowPolicy(object)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Keeps an authored floor choice live across theme changes while assigning
|
|
223
|
+
* the current theme's matching geometry-family recipe. This lets a stale
|
|
224
|
+
* sand/plank id render coherently now and recover its exact authored recipe
|
|
225
|
+
* if that theme is selected later.
|
|
226
|
+
*/
|
|
227
|
+
rebindFloorAppearanceForTheme(root: THREE.Object3D, authoredPresetId: string | undefined): void {
|
|
228
|
+
if (!authoredPresetId) return
|
|
229
|
+
this.assertActive()
|
|
230
|
+
const resolvedPresetId = resolvedFloorMaterialIdForTheme(this.theme, authoredPresetId)
|
|
231
|
+
const material = resolvedPresetId
|
|
232
|
+
? this.getAppearance(resolvedPresetId, 'lane.surface')
|
|
233
|
+
: this.get('lane.surface')
|
|
234
|
+
|
|
235
|
+
root.traverse(object => {
|
|
236
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
237
|
+
const meshMaterials = Array.isArray(object.material) ? object.material : [object.material]
|
|
238
|
+
meshMaterials.forEach((_meshMaterial, materialIndex) => {
|
|
239
|
+
const key = Array.isArray(object.material) ? materialIndex : -1
|
|
240
|
+
const slot = this.bindingIndex.get(object)?.get(key)?.slot
|
|
241
|
+
const appearanceSlot = this.appearanceBindingIndex.get(object)?.get(key)?.fallbackSlot
|
|
242
|
+
if (slot !== 'lane.surface' && appearanceSlot !== 'lane.surface') return
|
|
243
|
+
assignMaterial(object, material, Array.isArray(object.material) ? materialIndex : undefined)
|
|
244
|
+
object.userData.appearanceMaterialId = authoredPresetId
|
|
245
|
+
object.userData.materialSlot = 'lane.surface'
|
|
246
|
+
this.rememberAppearanceBinding({
|
|
247
|
+
mesh: object,
|
|
248
|
+
presetId: authoredPresetId,
|
|
249
|
+
fallbackSlot: 'lane.surface',
|
|
250
|
+
...(Array.isArray(object.material) ? { materialIndex } : {}),
|
|
251
|
+
resolveFloorByTheme: true,
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
applyHolographicFloorShadowPolicy(object)
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Releases every strong mesh reference captured below `root`.
|
|
260
|
+
*
|
|
261
|
+
* Track Lab recreates its preview meshes whenever the document changes. A
|
|
262
|
+
* registry is scene-scoped and outlives those previews, so leaving their
|
|
263
|
+
* bindings registered would retain every disposed document revision and
|
|
264
|
+
* continue rebinding dead meshes on later theme changes.
|
|
265
|
+
*/
|
|
266
|
+
releaseBindings(root: THREE.Object3D): void {
|
|
267
|
+
root.traverse(object => {
|
|
268
|
+
this.visibilityBindings.delete(object)
|
|
269
|
+
this.semanticThemeBindings.delete(object as ThemeVisualBinding)
|
|
270
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
271
|
+
|
|
272
|
+
const bindings = this.bindingIndex.get(object)
|
|
273
|
+
if (bindings) {
|
|
274
|
+
bindings.forEach(binding => this.bindings.delete(binding))
|
|
275
|
+
this.bindingIndex.delete(object)
|
|
276
|
+
}
|
|
277
|
+
const appearanceBindings = this.appearanceBindingIndex.get(object)
|
|
278
|
+
if (appearanceBindings) {
|
|
279
|
+
appearanceBindings.forEach(binding => this.appearanceBindings.delete(binding))
|
|
280
|
+
this.appearanceBindingIndex.delete(object)
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Moves a detached visual from a short-lived preparation registry into this
|
|
287
|
+
* scene registry without rebuilding its geometry. Material objects are
|
|
288
|
+
* rebound to this registry's active theme before the source can be disposed.
|
|
289
|
+
*/
|
|
290
|
+
adoptBindingsFrom(source: ThemeMaterialRegistry, root: THREE.Object3D): void {
|
|
291
|
+
this.assertActive()
|
|
292
|
+
source.assertActive()
|
|
293
|
+
const adoptedSemanticBindings: ThemeVisualBinding[] = []
|
|
294
|
+
root.traverse(object => {
|
|
295
|
+
if (source.semanticThemeBindings.delete(object as ThemeVisualBinding)) {
|
|
296
|
+
const semantic = object as ThemeVisualBinding
|
|
297
|
+
this.semanticThemeBindings.add(semantic)
|
|
298
|
+
adoptedSemanticBindings.push(semantic)
|
|
299
|
+
}
|
|
300
|
+
if (source.visibilityBindings.delete(object)) this.visibilityBindings.add(object)
|
|
301
|
+
applyThemeVisibility(object, this.theme.id)
|
|
302
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
303
|
+
|
|
304
|
+
const bindings = source.bindingIndex.get(object)
|
|
305
|
+
if (bindings) {
|
|
306
|
+
for (const [key, binding] of bindings) {
|
|
307
|
+
source.bindings.delete(binding)
|
|
308
|
+
const materialIndex = key < 0 ? undefined : key
|
|
309
|
+
assignMaterial(object, this.get(binding.slot), materialIndex)
|
|
310
|
+
this.rememberBinding({ mesh: object, slot: binding.slot, materialIndex })
|
|
311
|
+
}
|
|
312
|
+
source.bindingIndex.delete(object)
|
|
313
|
+
}
|
|
314
|
+
const appearanceBindings = source.appearanceBindingIndex.get(object)
|
|
315
|
+
if (appearanceBindings) {
|
|
316
|
+
for (const [key, binding] of appearanceBindings) {
|
|
317
|
+
source.appearanceBindings.delete(binding)
|
|
318
|
+
const materialIndex = key < 0 ? undefined : key
|
|
319
|
+
const resolvedPresetId = binding.resolveFloorByTheme
|
|
320
|
+
? resolvedFloorMaterialIdForTheme(this.theme, binding.presetId)
|
|
321
|
+
: binding.presetId
|
|
322
|
+
const material = resolvedPresetId
|
|
323
|
+
? this.getAppearance(resolvedPresetId, binding.fallbackSlot)
|
|
324
|
+
: this.get(binding.fallbackSlot)
|
|
325
|
+
assignMaterial(object, material, materialIndex)
|
|
326
|
+
this.rememberAppearanceBinding({ ...binding, mesh: object, materialIndex })
|
|
327
|
+
}
|
|
328
|
+
source.appearanceBindingIndex.delete(object)
|
|
329
|
+
}
|
|
330
|
+
applyHolographicFloorShadowPolicy(object)
|
|
331
|
+
})
|
|
332
|
+
// Semantic treatments can configure the material currently assigned to a
|
|
333
|
+
// mesh (for example, the canonical floor shoulder feather shader). Run
|
|
334
|
+
// them only after every source material has been replaced by this
|
|
335
|
+
// registry's live material.
|
|
336
|
+
for (const semantic of adoptedSemanticBindings) {
|
|
337
|
+
semantic.userData.applySemanticTheme?.(this.theme)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
applyTheme(theme: TrackThemeDefinition): void {
|
|
342
|
+
this.assertActive()
|
|
343
|
+
const obsoleteMaterials: THREE.Material[] = []
|
|
344
|
+
|
|
345
|
+
for (const [slot, current] of this.materials) {
|
|
346
|
+
const recipe = theme.materials[slot]
|
|
347
|
+
if (canUpdateInPlace(current, recipe)) {
|
|
348
|
+
applyRecipe(current, recipe)
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const replacement = createMaterial(slot, recipe)
|
|
353
|
+
this.materials.set(slot, replacement)
|
|
354
|
+
for (const binding of this.bindings) {
|
|
355
|
+
if (binding.slot === slot) {
|
|
356
|
+
assignMaterial(binding.mesh, replacement, binding.materialIndex)
|
|
357
|
+
applyHolographicFloorShadowPolicy(binding.mesh)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
obsoleteMaterials.push(current)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const [key, current] of this.appearanceMaterials) {
|
|
364
|
+
const appearance = parseAppearanceMaterialKey(key)
|
|
365
|
+
const recipe = findAppearanceRecipe(theme, appearance.presetId) ?? theme.materials[appearance.fallbackSlot]
|
|
366
|
+
if (canUpdateInPlace(current, recipe)) {
|
|
367
|
+
applyRecipe(current, recipe)
|
|
368
|
+
const preset = findAppearancePreset(theme, appearance.presetId)
|
|
369
|
+
applyAppearanceMetadata(current, preset?.canopyStyle, preset?.supportStyle)
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
const preset = findAppearancePreset(theme, appearance.presetId)
|
|
373
|
+
const replacement = createAppearanceMaterial(
|
|
374
|
+
appearance.presetId,
|
|
375
|
+
appearance.fallbackSlot,
|
|
376
|
+
recipe,
|
|
377
|
+
preset?.canopyStyle,
|
|
378
|
+
preset?.supportStyle,
|
|
379
|
+
)
|
|
380
|
+
this.appearanceMaterials.set(key, replacement)
|
|
381
|
+
for (const binding of this.appearanceBindings) {
|
|
382
|
+
if (
|
|
383
|
+
!binding.resolveFloorByTheme &&
|
|
384
|
+
binding.presetId === appearance.presetId &&
|
|
385
|
+
binding.fallbackSlot === appearance.fallbackSlot
|
|
386
|
+
) {
|
|
387
|
+
assignMaterial(binding.mesh, replacement, binding.materialIndex)
|
|
388
|
+
applyHolographicFloorShadowPolicy(binding.mesh)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
obsoleteMaterials.push(current)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.theme = theme
|
|
395
|
+
for (const binding of this.appearanceBindings) {
|
|
396
|
+
if (!binding.resolveFloorByTheme) continue
|
|
397
|
+
const resolvedPresetId = resolvedFloorMaterialIdForTheme(theme, binding.presetId)
|
|
398
|
+
const material = resolvedPresetId
|
|
399
|
+
? this.getAppearance(resolvedPresetId, binding.fallbackSlot)
|
|
400
|
+
: this.get(binding.fallbackSlot)
|
|
401
|
+
assignMaterial(binding.mesh, material, binding.materialIndex)
|
|
402
|
+
applyHolographicFloorShadowPolicy(binding.mesh)
|
|
403
|
+
}
|
|
404
|
+
for (const object of this.semanticThemeBindings) {
|
|
405
|
+
object.userData.applySemanticTheme?.(theme)
|
|
406
|
+
}
|
|
407
|
+
for (const object of this.visibilityBindings) {
|
|
408
|
+
applyThemeVisibility(object, theme.id)
|
|
409
|
+
}
|
|
410
|
+
for (const binding of this.bindings) {
|
|
411
|
+
if (!this.visibilityBindings.has(binding.mesh)) applyThemeVisibility(binding.mesh, theme.id)
|
|
412
|
+
}
|
|
413
|
+
obsoleteMaterials.forEach(disposeMaterial)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
dispose(): void {
|
|
417
|
+
if (this.disposed) return
|
|
418
|
+
this.disposed = true
|
|
419
|
+
this.materials.forEach(disposeMaterial)
|
|
420
|
+
this.appearanceMaterials.forEach(disposeMaterial)
|
|
421
|
+
this.materials.clear()
|
|
422
|
+
this.appearanceMaterials.clear()
|
|
423
|
+
this.bindings.clear()
|
|
424
|
+
this.appearanceBindings.clear()
|
|
425
|
+
this.visibilityBindings.clear()
|
|
426
|
+
this.semanticThemeBindings.clear()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private assertActive(): void {
|
|
430
|
+
if (this.disposed) throw new Error('ThemeMaterialRegistry has been disposed.')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private rememberBinding(binding: MaterialBinding): void {
|
|
434
|
+
const key = binding.materialIndex ?? -1
|
|
435
|
+
const bindings = this.bindingIndex.get(binding.mesh) ?? new Map<number, MaterialBinding>()
|
|
436
|
+
const current = bindings.get(key)
|
|
437
|
+
if (current?.slot === binding.slot) return
|
|
438
|
+
if (current) this.bindings.delete(current)
|
|
439
|
+
this.forgetAppearanceBinding(binding.mesh, key)
|
|
440
|
+
bindings.set(key, binding)
|
|
441
|
+
this.bindingIndex.set(binding.mesh, bindings)
|
|
442
|
+
this.bindings.add(binding)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private rememberAppearanceBinding(binding: AppearanceMaterialBinding): void {
|
|
446
|
+
const key = binding.materialIndex ?? -1
|
|
447
|
+
const bindings = this.appearanceBindingIndex.get(binding.mesh) ?? new Map<number, AppearanceMaterialBinding>()
|
|
448
|
+
const current = bindings.get(key)
|
|
449
|
+
if (
|
|
450
|
+
current?.presetId === binding.presetId &&
|
|
451
|
+
current.fallbackSlot === binding.fallbackSlot &&
|
|
452
|
+
current.resolveFloorByTheme === binding.resolveFloorByTheme
|
|
453
|
+
) {
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
if (current) this.appearanceBindings.delete(current)
|
|
457
|
+
this.forgetBinding(binding.mesh, key)
|
|
458
|
+
bindings.set(key, binding)
|
|
459
|
+
this.appearanceBindingIndex.set(binding.mesh, bindings)
|
|
460
|
+
this.appearanceBindings.add(binding)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private forgetBinding(mesh: THREE.Mesh, key: number): void {
|
|
464
|
+
const bindings = this.bindingIndex.get(mesh)
|
|
465
|
+
const current = bindings?.get(key)
|
|
466
|
+
if (!bindings || !current) return
|
|
467
|
+
this.bindings.delete(current)
|
|
468
|
+
bindings.delete(key)
|
|
469
|
+
if (bindings.size === 0) this.bindingIndex.delete(mesh)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private forgetAppearanceBinding(mesh: THREE.Mesh, key: number): void {
|
|
473
|
+
const bindings = this.appearanceBindingIndex.get(mesh)
|
|
474
|
+
const current = bindings?.get(key)
|
|
475
|
+
if (!bindings || !current) return
|
|
476
|
+
this.appearanceBindings.delete(current)
|
|
477
|
+
bindings.delete(key)
|
|
478
|
+
if (bindings.size === 0) this.appearanceBindingIndex.delete(mesh)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function applyHolographicFloorShadowPolicy(mesh: THREE.Mesh): void {
|
|
483
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|
484
|
+
const usesHolographicFloor = materials.some(material => material.userData.themeHolographicHoverGrid === true)
|
|
485
|
+
const originalKey = 'themeCastShadowBeforeHolographicFloor'
|
|
486
|
+
if (usesHolographicFloor) {
|
|
487
|
+
if (mesh.userData[originalKey] === undefined) mesh.userData[originalKey] = mesh.castShadow
|
|
488
|
+
mesh.castShadow = false
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
const original = mesh.userData[originalKey]
|
|
492
|
+
if (typeof original === 'boolean') mesh.castShadow = original
|
|
493
|
+
delete mesh.userData[originalKey]
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function appearanceMaterialKey(presetId: string, fallbackSlot: ThemeMaterialSlot): string {
|
|
497
|
+
return `${fallbackSlot}\u0000${presetId}`
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function parseAppearanceMaterialKey(key: string): { presetId: string; fallbackSlot: ThemeMaterialSlot } {
|
|
501
|
+
const separator = key.indexOf('\u0000')
|
|
502
|
+
return {
|
|
503
|
+
fallbackSlot: key.slice(0, separator) as ThemeMaterialSlot,
|
|
504
|
+
presetId: key.slice(separator + 1),
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function findAppearancePreset(theme: TrackThemeDefinition, presetId: string): TrackAppearancePreset | undefined {
|
|
509
|
+
return [
|
|
510
|
+
...(theme.appearancePresets?.floor ?? []),
|
|
511
|
+
...(theme.appearancePresets?.wall ?? []),
|
|
512
|
+
...(theme.appearancePresets?.rail ?? []),
|
|
513
|
+
...(theme.appearancePresets?.support ?? []),
|
|
514
|
+
].find(preset => preset.id === presetId)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function findAppearanceRecipe(theme: TrackThemeDefinition, presetId: string): MaterialRecipe | undefined {
|
|
518
|
+
return theme.detailMaterials?.[presetId] ?? findAppearancePreset(theme, presetId)?.material
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function createMaterial(slot: ThemeMaterialSlot, recipe: MaterialRecipe): THREE.Material {
|
|
522
|
+
const material =
|
|
523
|
+
recipe.kind === 'physical'
|
|
524
|
+
? new THREE.MeshPhysicalMaterial()
|
|
525
|
+
: recipe.kind === 'toon'
|
|
526
|
+
? new THREE.MeshToonMaterial()
|
|
527
|
+
: new THREE.MeshStandardMaterial()
|
|
528
|
+
material.name = `theme:${slot}`
|
|
529
|
+
material.userData.themeRegistryManaged = true
|
|
530
|
+
applyRecipe(material, recipe)
|
|
531
|
+
return material
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function createAppearanceMaterial(
|
|
535
|
+
presetId: string,
|
|
536
|
+
fallbackSlot: ThemeMaterialSlot,
|
|
537
|
+
recipe: MaterialRecipe,
|
|
538
|
+
canopyStyle?: TrackAppearanceCanopyStyle,
|
|
539
|
+
supportStyle?: TrackAppearanceSupportStyle,
|
|
540
|
+
): THREE.Material {
|
|
541
|
+
const material = createMaterial(fallbackSlot, recipe)
|
|
542
|
+
material.name = `theme-appearance:${presetId}`
|
|
543
|
+
material.userData.themeAppearancePresetId = presetId
|
|
544
|
+
material.userData.themeAppearanceFallbackSlot = fallbackSlot
|
|
545
|
+
applyAppearanceMetadata(material, canopyStyle, supportStyle)
|
|
546
|
+
return material
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function applyAppearanceMetadata(
|
|
550
|
+
material: THREE.Material,
|
|
551
|
+
canopyStyle?: TrackAppearanceCanopyStyle,
|
|
552
|
+
supportStyle?: TrackAppearanceSupportStyle,
|
|
553
|
+
): void {
|
|
554
|
+
if (canopyStyle) material.userData.themeAppearanceCanopyStyle = canopyStyle
|
|
555
|
+
else delete material.userData.themeAppearanceCanopyStyle
|
|
556
|
+
if (supportStyle) {
|
|
557
|
+
material.userData.themeAppearanceSupportStyle = supportStyle
|
|
558
|
+
applyTransparentSupportFadeShader(material, true)
|
|
559
|
+
} else {
|
|
560
|
+
delete material.userData.themeAppearanceSupportStyle
|
|
561
|
+
delete material.userData.transparentSupportFadeShader
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Applies the shared under-track alpha fade. Both direct and instanced support
|
|
567
|
+
* geometry use the same local-depth interval while retaining normal FrontSide
|
|
568
|
+
* culling. Visible transparent fragments do not write depth. Support builders
|
|
569
|
+
* pair them with a color-disabled prepass that seals the nearest exterior
|
|
570
|
+
* throughout the fade. The matching colour pass then uses EqualDepth so rear
|
|
571
|
+
* faces and overlapping internal courses cannot bleed through that exterior.
|
|
572
|
+
*/
|
|
573
|
+
export function applyTransparentSupportFadeShader(
|
|
574
|
+
material: THREE.Material,
|
|
575
|
+
force = false,
|
|
576
|
+
opaqueDepthPrepass = false,
|
|
577
|
+
): void {
|
|
578
|
+
if (!force && material.userData.transparentSupportFadeShader === true) return
|
|
579
|
+
const compileBase = material.onBeforeCompile.bind(material)
|
|
580
|
+
const cacheKeyBase = material.customProgramCacheKey.bind(material)
|
|
581
|
+
const materialWithDefaults = material as THREE.Material & { defaultAttributeValues?: Record<string, number[]> }
|
|
582
|
+
materialWithDefaults.defaultAttributeValues = {
|
|
583
|
+
...materialWithDefaults.defaultAttributeValues,
|
|
584
|
+
supportFade: [1],
|
|
585
|
+
supportFadeDepthMode: [0],
|
|
586
|
+
supportFadeDepth: [0],
|
|
587
|
+
supportFadeStart: [0],
|
|
588
|
+
supportFadeEnd: [1],
|
|
589
|
+
supportFadeBottom: [0],
|
|
590
|
+
supportFadeTop: [1],
|
|
591
|
+
}
|
|
592
|
+
material.onBeforeCompile = (shader, renderer) => {
|
|
593
|
+
compileBase(shader, renderer)
|
|
594
|
+
shader.vertexShader =
|
|
595
|
+
`attribute float supportFade;
|
|
596
|
+
#ifndef USE_INSTANCING
|
|
597
|
+
attribute float supportFadeDepthMode;
|
|
598
|
+
attribute float supportFadeDepth;
|
|
599
|
+
attribute float supportFadeStart;
|
|
600
|
+
attribute float supportFadeEnd;
|
|
601
|
+
#endif
|
|
602
|
+
varying float vSupportFadeCoverage;
|
|
603
|
+
varying float vSupportFadeHeight;
|
|
604
|
+
varying vec2 vSupportFadeRange;
|
|
605
|
+
#ifdef USE_INSTANCING
|
|
606
|
+
attribute float supportFadeBottom;
|
|
607
|
+
attribute float supportFadeTop;
|
|
608
|
+
#endif
|
|
609
|
+
` +
|
|
610
|
+
shader.vertexShader.replace(
|
|
611
|
+
'#include <project_vertex>',
|
|
612
|
+
`#include <project_vertex>
|
|
613
|
+
#ifdef USE_INSTANCING
|
|
614
|
+
vec3 supportFadePosition = (instanceMatrix * vec4(transformed, 1.0)).xyz;
|
|
615
|
+
vSupportFadeCoverage = 1.0;
|
|
616
|
+
vSupportFadeHeight = supportFadePosition.y;
|
|
617
|
+
vSupportFadeRange = vec2(supportFadeBottom, supportFadeTop);
|
|
618
|
+
#else
|
|
619
|
+
// Procedural meshes keep their established per-vertex coverage.
|
|
620
|
+
// Authored meshes with faces that cross a fade boundary explicitly
|
|
621
|
+
// opt into interpolating physical depth before fragment evaluation.
|
|
622
|
+
vSupportFadeCoverage = mix(supportFade, 1.0, supportFadeDepthMode);
|
|
623
|
+
vSupportFadeHeight = mix(1.0, -supportFadeDepth, supportFadeDepthMode);
|
|
624
|
+
vSupportFadeRange = mix(
|
|
625
|
+
vec2(0.0, 1.0),
|
|
626
|
+
vec2(-supportFadeEnd, -supportFadeStart),
|
|
627
|
+
supportFadeDepthMode
|
|
628
|
+
);
|
|
629
|
+
#endif`,
|
|
630
|
+
)
|
|
631
|
+
const fadeFragment = opaqueDepthPrepass
|
|
632
|
+
? `if (supportMaskCoverage < 0.01) discard;
|
|
633
|
+
diffuseColor.a = 1.0;`
|
|
634
|
+
: `diffuseColor.a *= clamp(supportMaskCoverage, 0.0, 1.0);
|
|
635
|
+
if (diffuseColor.a < 0.01) discard;`
|
|
636
|
+
shader.fragmentShader =
|
|
637
|
+
`varying float vSupportFadeCoverage;
|
|
638
|
+
varying float vSupportFadeHeight;
|
|
639
|
+
varying vec2 vSupportFadeRange;
|
|
640
|
+
` +
|
|
641
|
+
shader.fragmentShader.replace(
|
|
642
|
+
'#include <alphatest_fragment>',
|
|
643
|
+
`#include <alphatest_fragment>
|
|
644
|
+
float supportHeightCoverage = smoothstep(
|
|
645
|
+
vSupportFadeRange.x,
|
|
646
|
+
max(vSupportFadeRange.x + 0.001, vSupportFadeRange.y),
|
|
647
|
+
vSupportFadeHeight
|
|
648
|
+
);
|
|
649
|
+
float supportMaskCoverage = min(vSupportFadeCoverage, supportHeightCoverage);
|
|
650
|
+
${fadeFragment}`,
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
material.customProgramCacheKey = () =>
|
|
654
|
+
`${cacheKeyBase()}:transparent-support-height-fade-v3:${opaqueDepthPrepass ? 'opaque-depth' : 'color'}`
|
|
655
|
+
material.userData.transparentSupportFadeShader = true
|
|
656
|
+
material.userData.supportFadeOpaqueDepthPrepass = opaqueDepthPrepass
|
|
657
|
+
delete material.userData.opaqueSupportFadeShader
|
|
658
|
+
material.transparent = true
|
|
659
|
+
material.depthWrite = false
|
|
660
|
+
material.needsUpdate = true
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function canUpdateInPlace(material: THREE.Material, recipe: MaterialRecipe): boolean {
|
|
664
|
+
const currentPattern = material.userData.themePatternSignature
|
|
665
|
+
const nextPattern = patternSignature(recipe.pattern)
|
|
666
|
+
if (currentPattern !== nextPattern) return false
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
(recipe.kind === 'standard' &&
|
|
670
|
+
material instanceof THREE.MeshStandardMaterial &&
|
|
671
|
+
!(material instanceof THREE.MeshPhysicalMaterial)) ||
|
|
672
|
+
(recipe.kind === 'physical' && material instanceof THREE.MeshPhysicalMaterial) ||
|
|
673
|
+
(recipe.kind === 'toon' && material instanceof THREE.MeshToonMaterial)
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function applyRecipe(material: THREE.Material, recipe: MaterialRecipe): void {
|
|
678
|
+
if (!(material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshToonMaterial)) return
|
|
679
|
+
|
|
680
|
+
material.color.set(recipe.color)
|
|
681
|
+
material.transparent = recipe.transparent ?? false
|
|
682
|
+
material.opacity = recipe.opacity ?? 1
|
|
683
|
+
material.alphaTest = recipe.alphaTest ?? 0
|
|
684
|
+
material.depthWrite = recipe.depthWrite ?? true
|
|
685
|
+
material.blending = THREE.NormalBlending
|
|
686
|
+
material.toneMapped = true
|
|
687
|
+
material.wireframe = false
|
|
688
|
+
const nextPatternSignature = patternSignature(recipe.pattern)
|
|
689
|
+
const nextMapSignature = mapSignature(recipe)
|
|
690
|
+
if (material.userData.themeMapSignature !== nextMapSignature) {
|
|
691
|
+
const previousMap = material.map
|
|
692
|
+
disposeTexture(previousMap)
|
|
693
|
+
disposeNeonNetTexture(material)
|
|
694
|
+
disposeWeatheredMetalResponseTextures(material)
|
|
695
|
+
if (material instanceof THREE.MeshStandardMaterial) {
|
|
696
|
+
for (const texture of new Set([
|
|
697
|
+
material.normalMap,
|
|
698
|
+
material.bumpMap,
|
|
699
|
+
material.displacementMap,
|
|
700
|
+
material.roughnessMap,
|
|
701
|
+
material.aoMap,
|
|
702
|
+
material.emissiveMap,
|
|
703
|
+
])) {
|
|
704
|
+
if (texture && texture !== previousMap) disposeTexture(texture)
|
|
705
|
+
}
|
|
706
|
+
material.normalMap = null
|
|
707
|
+
material.bumpMap = null
|
|
708
|
+
material.displacementMap = null
|
|
709
|
+
material.roughnessMap = null
|
|
710
|
+
material.aoMap = null
|
|
711
|
+
material.emissiveMap = null
|
|
712
|
+
}
|
|
713
|
+
material.map = createRecipeTexture(recipe)
|
|
714
|
+
material.userData.themeMapSignature = nextMapSignature
|
|
715
|
+
material.userData.themePatternSignature = nextPatternSignature
|
|
716
|
+
if (recipe.pattern?.kind === 'weathered-sheet-metal' && material instanceof THREE.MeshStandardMaterial) {
|
|
717
|
+
const response = createWeatheredMetalResponseTextures(recipe.pattern)
|
|
718
|
+
material.bumpMap = response.height
|
|
719
|
+
material.roughnessMap = response.roughness
|
|
720
|
+
material.metalnessMap = response.metalness
|
|
721
|
+
material.userData.themeWeatheredMetalResponse = response
|
|
722
|
+
} else if (material instanceof THREE.MeshStandardMaterial) {
|
|
723
|
+
material.normalMap = createRecipeResponseTexture(recipe.normalTextureUrl, recipe, 'normal')
|
|
724
|
+
material.bumpMap = createRecipeResponseTexture(recipe.heightTextureUrl, recipe, 'height')
|
|
725
|
+
material.displacementMap = null
|
|
726
|
+
material.roughnessMap = createRecipeResponseTexture(recipe.roughnessTextureUrl, recipe, 'roughness')
|
|
727
|
+
material.aoMap = createRecipeResponseTexture(recipe.aoTextureUrl, recipe, 'ao')
|
|
728
|
+
material.emissiveMap = createRecipeResponseTexture(recipe.emissiveTextureUrl, recipe, 'emissive')
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (recipe.pattern?.kind !== 'weathered-sheet-metal' && material instanceof THREE.MeshStandardMaterial) {
|
|
732
|
+
if (!recipe.heightTextureUrl) material.bumpMap = null
|
|
733
|
+
material.metalnessMap = null
|
|
734
|
+
material.normalScale.set(...(recipe.normalScale ?? [1, 1]))
|
|
735
|
+
material.aoMapIntensity = recipe.aoMapIntensity ?? 1
|
|
736
|
+
}
|
|
737
|
+
material.bumpScale = recipe.bumpScale ?? 1
|
|
738
|
+
material.displacementMap = null
|
|
739
|
+
material.displacementScale = recipe.visualRelief?.scale ?? 0
|
|
740
|
+
material.displacementBias = recipe.visualRelief?.bias ?? 0
|
|
741
|
+
if (recipe.visualRelief) {
|
|
742
|
+
material.userData.themeVisualRelief = { ...recipe.visualRelief }
|
|
743
|
+
} else {
|
|
744
|
+
delete material.userData.themeVisualRelief
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
material.emissive.set(recipe.emissive ?? '#000000')
|
|
748
|
+
material.emissiveIntensity = recipe.emissiveIntensity ?? 0
|
|
749
|
+
|
|
750
|
+
if (material instanceof THREE.MeshStandardMaterial) {
|
|
751
|
+
material.roughness = recipe.roughness
|
|
752
|
+
material.metalness = recipe.metalness
|
|
753
|
+
material.envMapIntensity = recipe.envMapIntensity ?? 1
|
|
754
|
+
if (recipe.kind === 'physical' && material instanceof THREE.MeshPhysicalMaterial) {
|
|
755
|
+
material.clearcoat = recipe.clearcoat ?? 0
|
|
756
|
+
material.clearcoatRoughness = recipe.clearcoatRoughness ?? 0
|
|
757
|
+
material.transmission = recipe.transmission ?? 0
|
|
758
|
+
material.thickness = recipe.thickness ?? 0
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (recipe.kind === 'toon' && material instanceof THREE.MeshToonMaterial) {
|
|
763
|
+
const gradientSignature = `${recipe.gradientSteps ?? 4}`
|
|
764
|
+
if (material.userData.themeGradientSignature !== gradientSignature) {
|
|
765
|
+
material.gradientMap?.dispose()
|
|
766
|
+
material.gradientMap = createToonGradientTexture(recipe.gradientSteps ?? 4)
|
|
767
|
+
material.userData.themeGradientSignature = gradientSignature
|
|
768
|
+
}
|
|
769
|
+
applyToonInk(material, recipe, material.map ? activeMaterialTextureWorldSize(recipe) : undefined)
|
|
770
|
+
} else if (recipe.pattern?.kind === 'neon-net' && material instanceof THREE.MeshStandardMaterial) {
|
|
771
|
+
applyNeonNetShader(
|
|
772
|
+
material,
|
|
773
|
+
recipe.netSpacing ?? 0.82,
|
|
774
|
+
recipe.netLineWidth ?? 0.06,
|
|
775
|
+
recipe.netFillOpacity ?? 0,
|
|
776
|
+
recipe.pattern.primary,
|
|
777
|
+
)
|
|
778
|
+
} else if (recipe.pattern?.kind === 'holographic-hover-grid' && material instanceof THREE.MeshStandardMaterial) {
|
|
779
|
+
applyHolographicHoverGridShader(material, recipe)
|
|
780
|
+
} else if (material.map) {
|
|
781
|
+
applyMetricMapShader(material, activeMaterialTextureWorldSize(recipe), recipe.visualRelief, recipe.emissiveSurface)
|
|
782
|
+
} else {
|
|
783
|
+
material.onBeforeCompile = () => {}
|
|
784
|
+
material.customProgramCacheKey = () => ''
|
|
785
|
+
}
|
|
786
|
+
material.needsUpdate = true
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function applyHolographicHoverGridShader(material: THREE.MeshStandardMaterial, recipe: MaterialRecipe): void {
|
|
790
|
+
const pattern = recipe.pattern
|
|
791
|
+
if (!pattern || pattern.kind !== 'holographic-hover-grid') return
|
|
792
|
+
|
|
793
|
+
const spacingX = Math.max(0.001, pattern.worldSize[0])
|
|
794
|
+
const spacingY = Math.max(0.001, pattern.worldSize[1])
|
|
795
|
+
const coreWidth = Math.max(0.0001, recipe.hoverGridCoreWidth ?? 0.0045)
|
|
796
|
+
const auraFalloff = Math.max(0.001, recipe.hoverGridAuraFalloff ?? 46)
|
|
797
|
+
const auraStrength = Math.max(0, recipe.hoverGridAuraStrength ?? 0.62)
|
|
798
|
+
const junctionStrength = Math.max(0, recipe.hoverGridJunctionStrength ?? 0.34)
|
|
799
|
+
const upwardOnly = recipe.emissiveSurface === 'upward'
|
|
800
|
+
configureThemeMetricAttribute(material)
|
|
801
|
+
|
|
802
|
+
material.onBeforeCompile = shader => {
|
|
803
|
+
shader.uniforms.themeHoverGridColor = { value: new THREE.Color(pattern.primary) }
|
|
804
|
+
shader.uniforms.themeHoverGridHotColor = { value: new THREE.Color(pattern.secondary) }
|
|
805
|
+
shader.vertexShader =
|
|
806
|
+
'varying vec3 vThemeHoverGridPosition;\nvarying vec3 vThemeHoverGridNormal;\n' +
|
|
807
|
+
shader.vertexShader.replace(
|
|
808
|
+
'#include <project_vertex>',
|
|
809
|
+
`#include <project_vertex>
|
|
810
|
+
vec3 themeHoverGridPosition = transformed;
|
|
811
|
+
vec3 themeHoverGridNormal = objectNormal;
|
|
812
|
+
#ifdef USE_INSTANCING
|
|
813
|
+
themeHoverGridPosition = (instanceMatrix * vec4(themeHoverGridPosition, 1.0)).xyz;
|
|
814
|
+
mat3 themeHoverGridInstanceNormalMatrix = mat3(instanceMatrix);
|
|
815
|
+
themeHoverGridNormal /= vec3(
|
|
816
|
+
dot(themeHoverGridInstanceNormalMatrix[0], themeHoverGridInstanceNormalMatrix[0]),
|
|
817
|
+
dot(themeHoverGridInstanceNormalMatrix[1], themeHoverGridInstanceNormalMatrix[1]),
|
|
818
|
+
dot(themeHoverGridInstanceNormalMatrix[2], themeHoverGridInstanceNormalMatrix[2])
|
|
819
|
+
);
|
|
820
|
+
themeHoverGridNormal = themeHoverGridInstanceNormalMatrix * themeHoverGridNormal;
|
|
821
|
+
#endif
|
|
822
|
+
vec3 themeHoverGridWorldPosition = (modelMatrix * vec4(themeHoverGridPosition, 1.0)).xyz;
|
|
823
|
+
// Track Lab uniformly scales its complete presentation root to fit
|
|
824
|
+
// the workspace. That is camera staging, not material scale: remove
|
|
825
|
+
// it so a 0.28-unit grid remains 0.28 track units in Lab and races.
|
|
826
|
+
float themeHoverGridPresentationScale = max(
|
|
827
|
+
0.000001,
|
|
828
|
+
(length(modelMatrix[0].xyz) + length(modelMatrix[1].xyz) + length(modelMatrix[2].xyz)) / 3.0
|
|
829
|
+
);
|
|
830
|
+
vThemeHoverGridPosition = themeHoverGridWorldPosition / themeHoverGridPresentationScale;
|
|
831
|
+
vThemeHoverGridNormal = normalize(mat3(modelMatrix) * themeHoverGridNormal);`,
|
|
832
|
+
)
|
|
833
|
+
shader.fragmentShader =
|
|
834
|
+
'uniform vec3 themeHoverGridColor;\nuniform vec3 themeHoverGridHotColor;\nvarying vec3 vThemeHoverGridPosition;\nvarying vec3 vThemeHoverGridNormal;\n' +
|
|
835
|
+
shader.fragmentShader
|
|
836
|
+
.replace(
|
|
837
|
+
'#include <alphatest_fragment>',
|
|
838
|
+
`vec3 themeHoverNormal = abs(normalize(vThemeHoverGridNormal));
|
|
839
|
+
// A holographic floor can reuse a closed physical shell without
|
|
840
|
+
// illuminating its vertical thickness or downward underside.
|
|
841
|
+
if (${upwardOnly ? 'vThemeHoverGridNormal.y <= 0.001' : 'false'}) discard;
|
|
842
|
+
// One world-metric field is the source of truth for this hologram.
|
|
843
|
+
// Clipping it with independent floor meshes cannot introduce a seam,
|
|
844
|
+
// including at branches, loops, rotations, or editor/runtime roots.
|
|
845
|
+
vec2 themeHoverCoordinate =
|
|
846
|
+
themeHoverNormal.y >= themeHoverNormal.x && themeHoverNormal.y >= themeHoverNormal.z
|
|
847
|
+
? vThemeHoverGridPosition.xz
|
|
848
|
+
: themeHoverNormal.x >= themeHoverNormal.z
|
|
849
|
+
? vThemeHoverGridPosition.zy
|
|
850
|
+
: vThemeHoverGridPosition.xy;
|
|
851
|
+
vec2 themeHoverSpacing = vec2(${spacingX.toFixed(6)}, ${spacingY.toFixed(6)});
|
|
852
|
+
vec2 themeHoverDistanceToLine = abs(fract(themeHoverCoordinate / themeHoverSpacing + 0.5) - 0.5) * themeHoverSpacing;
|
|
853
|
+
float themeHoverEdgeDistance = min(themeHoverDistanceToLine.x, themeHoverDistanceToLine.y);
|
|
854
|
+
float themeHoverAntialias = max(fwidth(themeHoverEdgeDistance) * 1.15, 0.0012);
|
|
855
|
+
float themeHoverCore = 1.0 - smoothstep(${coreWidth.toFixed(6)} - themeHoverAntialias, ${coreWidth.toFixed(6)} + themeHoverAntialias, themeHoverEdgeDistance);
|
|
856
|
+
float themeHoverAura = exp(-themeHoverEdgeDistance * ${auraFalloff.toFixed(6)}) * ${auraStrength.toFixed(6)};
|
|
857
|
+
float themeHoverJunction = (1.0 - smoothstep(0.013, 0.026, themeHoverDistanceToLine.x))
|
|
858
|
+
* (1.0 - smoothstep(0.013, 0.026, themeHoverDistanceToLine.y));
|
|
859
|
+
float themeHoverIntensity = themeHoverCore * 1.72 + themeHoverAura * 1.18 + themeHoverJunction * ${junctionStrength.toFixed(6)};
|
|
860
|
+
if (themeHoverIntensity < 0.014) discard;
|
|
861
|
+
vec3 themeHoverHotColor = mix(
|
|
862
|
+
themeHoverGridColor,
|
|
863
|
+
themeHoverGridHotColor,
|
|
864
|
+
clamp(themeHoverCore * 0.72 + themeHoverJunction * 0.3, 0.0, 0.84)
|
|
865
|
+
);
|
|
866
|
+
float themeHoverAlpha = min(0.98, themeHoverCore * 0.94 + themeHoverAura * 0.68 + themeHoverJunction * 0.2);
|
|
867
|
+
diffuseColor = vec4(themeHoverHotColor * themeHoverIntensity, themeHoverAlpha);
|
|
868
|
+
#include <alphatest_fragment>`,
|
|
869
|
+
)
|
|
870
|
+
.replace('#include <opaque_fragment>', 'gl_FragColor = vec4(diffuseColor.rgb, diffuseColor.a);')
|
|
871
|
+
}
|
|
872
|
+
material.customProgramCacheKey = () =>
|
|
873
|
+
`holographic-hover-grid-v5-track-metric:${upwardOnly ? 'upward' : 'all'}:${spacingX.toFixed(6)}:${spacingY.toFixed(6)}:${coreWidth.toFixed(6)}:${auraFalloff.toFixed(3)}:${auraStrength.toFixed(3)}:${junctionStrength.toFixed(3)}`
|
|
874
|
+
material.transparent = true
|
|
875
|
+
material.depthWrite = false
|
|
876
|
+
material.blending = THREE.AdditiveBlending
|
|
877
|
+
material.toneMapped = false
|
|
878
|
+
material.userData.themeHolographicHoverGrid = true
|
|
879
|
+
const materialWithAttributeDefaults = material as THREE.MeshStandardMaterial & {
|
|
880
|
+
defaultAttributeValues?: Record<string, number[]>
|
|
881
|
+
}
|
|
882
|
+
materialWithAttributeDefaults.defaultAttributeValues = {
|
|
883
|
+
...materialWithAttributeDefaults.defaultAttributeValues,
|
|
884
|
+
themeHoverGridPhase: [0],
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
interface ThemeShaderSource {
|
|
889
|
+
uniforms: Record<string, { value: unknown }>
|
|
890
|
+
vertexShader: string
|
|
891
|
+
fragmentShader: string
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function configureThemeMetricAttribute(material: THREE.Material): void {
|
|
895
|
+
const materialWithAttributeDefaults = material as THREE.Material & {
|
|
896
|
+
defaultAttributeValues?: Record<string, number[]>
|
|
897
|
+
}
|
|
898
|
+
materialWithAttributeDefaults.defaultAttributeValues = {
|
|
899
|
+
...materialWithAttributeDefaults.defaultAttributeValues,
|
|
900
|
+
themeNetUv: [0, 0, 0, 0],
|
|
901
|
+
themeRelief: [0],
|
|
902
|
+
themeMaterialPhase: [0],
|
|
903
|
+
themeMaterialTransversePhase: [0],
|
|
904
|
+
themeMaterialLongitudinalAxis: [1],
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function injectMetricMapShader(
|
|
909
|
+
shader: ThemeShaderSource,
|
|
910
|
+
worldSize: readonly [number, number],
|
|
911
|
+
visualRelief?: MaterialRecipe['visualRelief'],
|
|
912
|
+
reliefMap: THREE.Texture | null = null,
|
|
913
|
+
emissiveSurface: MaterialRecipe['emissiveSurface'] = 'all',
|
|
914
|
+
): void {
|
|
915
|
+
const safeWorldSize: readonly [number, number] = [Math.max(0.001, worldSize[0]), Math.max(0.001, worldSize[1])]
|
|
916
|
+
shader.uniforms.themeTextureScale = {
|
|
917
|
+
value: new THREE.Vector2(1 / safeWorldSize[0], 1 / safeWorldSize[1]),
|
|
918
|
+
}
|
|
919
|
+
if (visualRelief) {
|
|
920
|
+
shader.uniforms.themeReliefMap = { value: reliefMap }
|
|
921
|
+
shader.uniforms.themeReliefScale = { value: visualRelief.scale }
|
|
922
|
+
shader.uniforms.themeReliefBias = { value: visualRelief.bias }
|
|
923
|
+
}
|
|
924
|
+
shader.vertexShader =
|
|
925
|
+
`${visualRelief ? 'attribute float themeRelief;\nuniform sampler2D themeReliefMap;\nuniform float themeReliefScale;\nuniform float themeReliefBias;\n' : ''}attribute vec4 themeNetUv;\nattribute float themeMaterialPhase;\nattribute float themeMaterialTransversePhase;\nattribute float themeMaterialLongitudinalAxis;\nuniform vec2 themeTextureScale;\nvarying vec4 vThemeMetricUv;\nvarying float vThemeMaterialPhase;\nvarying float vThemeMaterialTransversePhase;\nvarying float vThemeMaterialLongitudinalAxis;\nvarying vec3 vThemeMetricPosition;\nvarying vec3 vThemeMetricNormal;\n#ifdef USE_NORMALMAP\nvarying vec3 vThemeMetricAxisX;\nvarying vec3 vThemeMetricAxisY;\nvarying vec3 vThemeMetricAxisZ;\n#endif\n` +
|
|
926
|
+
shader.vertexShader
|
|
927
|
+
.replace(
|
|
928
|
+
'#include <begin_vertex>',
|
|
929
|
+
visualRelief
|
|
930
|
+
? `#include <begin_vertex>
|
|
931
|
+
#ifdef USE_BUMPMAP
|
|
932
|
+
transformed += normalize(objectNormal) *
|
|
933
|
+
(texture2D(
|
|
934
|
+
themeReliefMap,
|
|
935
|
+
vBumpMapUv + mix(
|
|
936
|
+
vec2(themeMaterialPhase * themeTextureScale.x, themeMaterialTransversePhase * themeTextureScale.y),
|
|
937
|
+
vec2(themeMaterialTransversePhase * themeTextureScale.x, themeMaterialPhase * themeTextureScale.y),
|
|
938
|
+
themeMaterialLongitudinalAxis
|
|
939
|
+
)
|
|
940
|
+
).x * themeReliefScale + themeReliefBias) *
|
|
941
|
+
themeRelief;
|
|
942
|
+
#endif`
|
|
943
|
+
: '#include <begin_vertex>',
|
|
944
|
+
)
|
|
945
|
+
.replace(
|
|
946
|
+
'#include <project_vertex>',
|
|
947
|
+
`#include <project_vertex>
|
|
948
|
+
vThemeMetricUv = themeNetUv;
|
|
949
|
+
vThemeMaterialPhase = themeMaterialPhase;
|
|
950
|
+
vThemeMaterialTransversePhase = themeMaterialTransversePhase;
|
|
951
|
+
vThemeMaterialLongitudinalAxis = themeMaterialLongitudinalAxis;
|
|
952
|
+
vec3 themeMetricPosition = transformed;
|
|
953
|
+
vec3 themeMetricNormal = objectNormal;
|
|
954
|
+
#ifdef USE_NORMALMAP
|
|
955
|
+
vec3 themeMetricAxisX = vec3(1.0, 0.0, 0.0);
|
|
956
|
+
vec3 themeMetricAxisY = vec3(0.0, 1.0, 0.0);
|
|
957
|
+
vec3 themeMetricAxisZ = vec3(0.0, 0.0, 1.0);
|
|
958
|
+
#endif
|
|
959
|
+
#ifdef USE_INSTANCING
|
|
960
|
+
themeMetricPosition = (instanceMatrix * vec4(themeMetricPosition, 1.0)).xyz;
|
|
961
|
+
mat3 themeInstanceNormalMatrix = mat3(instanceMatrix);
|
|
962
|
+
vec3 themeInstanceScaleSquared = vec3(
|
|
963
|
+
dot(themeInstanceNormalMatrix[0], themeInstanceNormalMatrix[0]),
|
|
964
|
+
dot(themeInstanceNormalMatrix[1], themeInstanceNormalMatrix[1]),
|
|
965
|
+
dot(themeInstanceNormalMatrix[2], themeInstanceNormalMatrix[2])
|
|
966
|
+
);
|
|
967
|
+
themeMetricNormal /= themeInstanceScaleSquared;
|
|
968
|
+
themeMetricNormal = themeInstanceNormalMatrix * themeMetricNormal;
|
|
969
|
+
#ifdef USE_NORMALMAP
|
|
970
|
+
themeMetricAxisX = themeInstanceNormalMatrix * (themeMetricAxisX / themeInstanceScaleSquared);
|
|
971
|
+
themeMetricAxisY = themeInstanceNormalMatrix * (themeMetricAxisY / themeInstanceScaleSquared);
|
|
972
|
+
themeMetricAxisZ = themeInstanceNormalMatrix * (themeMetricAxisZ / themeInstanceScaleSquared);
|
|
973
|
+
#endif
|
|
974
|
+
#endif
|
|
975
|
+
vThemeMetricPosition = themeMetricPosition;
|
|
976
|
+
vThemeMetricNormal = normalize(themeMetricNormal);
|
|
977
|
+
#ifdef USE_NORMALMAP
|
|
978
|
+
vThemeMetricAxisX = normalize(normalMatrix * themeMetricAxisX);
|
|
979
|
+
vThemeMetricAxisY = normalize(normalMatrix * themeMetricAxisY);
|
|
980
|
+
vThemeMetricAxisZ = normalize(normalMatrix * themeMetricAxisZ);
|
|
981
|
+
#endif`,
|
|
982
|
+
)
|
|
983
|
+
shader.fragmentShader =
|
|
984
|
+
'uniform vec2 themeTextureScale;\nvarying vec4 vThemeMetricUv;\nvarying float vThemeMaterialPhase;\nvarying float vThemeMaterialTransversePhase;\nvarying float vThemeMaterialLongitudinalAxis;\nvarying vec3 vThemeMetricPosition;\nvarying vec3 vThemeMetricNormal;\n#ifdef USE_NORMALMAP\nvarying vec3 vThemeMetricAxisX;\nvarying vec3 vThemeMetricAxisY;\nvarying vec3 vThemeMetricAxisZ;\n#endif\n' +
|
|
985
|
+
`vec3 themeMetricBlendWeights() {
|
|
986
|
+
vec3 weights = pow(abs(normalize(vThemeMetricNormal)), vec3(6.0));
|
|
987
|
+
return weights / max(weights.x + weights.y + weights.z, 0.0001);
|
|
988
|
+
}
|
|
989
|
+
vec2 themeAuthoredPhaseOffset() {
|
|
990
|
+
return mix(
|
|
991
|
+
vec2(vThemeMaterialPhase * themeTextureScale.x, vThemeMaterialTransversePhase * themeTextureScale.y),
|
|
992
|
+
vec2(vThemeMaterialTransversePhase * themeTextureScale.x, vThemeMaterialPhase * themeTextureScale.y),
|
|
993
|
+
vThemeMaterialLongitudinalAxis
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
vec2 themeOrientedSurfaceUv(vec2 authoredUv) {
|
|
997
|
+
// vMapUv and the response-map UV varyings already include each
|
|
998
|
+
// texture's repeat transform. Directional metric geometry therefore
|
|
999
|
+
// supplies world-unit UVs directly; multiplying by themeTextureScale
|
|
1000
|
+
// here would apply physical scale twice and enlarge the pattern.
|
|
1001
|
+
return authoredUv + themeAuthoredPhaseOffset();
|
|
1002
|
+
}
|
|
1003
|
+
vec4 themeSampleSurfaceMap(sampler2D themeSampler, vec2 authoredUv) {
|
|
1004
|
+
if (vThemeMetricUv.z > 0.5) {
|
|
1005
|
+
return texture2D(themeSampler, themeOrientedSurfaceUv(authoredUv));
|
|
1006
|
+
}
|
|
1007
|
+
vec3 weights = themeMetricBlendWeights();
|
|
1008
|
+
vec4 sampleX = texture2D(themeSampler, vThemeMetricPosition.zy * themeTextureScale);
|
|
1009
|
+
vec4 sampleY = texture2D(themeSampler, vThemeMetricPosition.xz * themeTextureScale);
|
|
1010
|
+
vec4 sampleZ = texture2D(themeSampler, vThemeMetricPosition.xy * themeTextureScale);
|
|
1011
|
+
return sampleX * weights.x + sampleY * weights.y + sampleZ * weights.z;
|
|
1012
|
+
}
|
|
1013
|
+
vec4 themeSampleSurfaceResponseMap(sampler2D themeSampler, vec2 authoredUv) {
|
|
1014
|
+
if (vThemeMetricUv.z > 0.5) {
|
|
1015
|
+
return texture2D(themeSampler, themeOrientedSurfaceUv(authoredUv));
|
|
1016
|
+
}
|
|
1017
|
+
vec3 weights = themeMetricBlendWeights();
|
|
1018
|
+
vec4 sampleX = texture2D(themeSampler, vThemeMetricPosition.zy * themeTextureScale);
|
|
1019
|
+
vec4 sampleY = texture2D(themeSampler, vThemeMetricPosition.xz * themeTextureScale);
|
|
1020
|
+
vec4 sampleZ = texture2D(themeSampler, vThemeMetricPosition.xy * themeTextureScale);
|
|
1021
|
+
return sampleX * weights.x + sampleY * weights.y + sampleZ * weights.z;
|
|
1022
|
+
}
|
|
1023
|
+
#ifdef USE_NORMALMAP
|
|
1024
|
+
vec3 themeSampleMetricNormal(sampler2D themeNormalSampler, vec2 themeNormalScale) {
|
|
1025
|
+
vec3 weights = themeMetricBlendWeights();
|
|
1026
|
+
vec3 axisSign = mix(vec3(-1.0), vec3(1.0), step(vec3(0.0), vThemeMetricNormal));
|
|
1027
|
+
vec3 sampleX = texture2D(themeNormalSampler, vThemeMetricPosition.zy * themeTextureScale).xyz * 2.0 - 1.0;
|
|
1028
|
+
vec3 sampleY = texture2D(themeNormalSampler, vThemeMetricPosition.xz * themeTextureScale).xyz * 2.0 - 1.0;
|
|
1029
|
+
vec3 sampleZ = texture2D(themeNormalSampler, vThemeMetricPosition.xy * themeTextureScale).xyz * 2.0 - 1.0;
|
|
1030
|
+
sampleX.xy *= themeNormalScale;
|
|
1031
|
+
sampleY.xy *= themeNormalScale;
|
|
1032
|
+
sampleZ.xy *= themeNormalScale;
|
|
1033
|
+
vec3 normalX = normalize(vec3(sampleX.z * axisSign.x, sampleX.y, sampleX.x * axisSign.x));
|
|
1034
|
+
vec3 normalY = normalize(vec3(sampleY.x, sampleY.z * axisSign.y, sampleY.y * axisSign.y));
|
|
1035
|
+
vec3 normalZ = normalize(vec3(sampleZ.x * axisSign.z, sampleZ.y, sampleZ.z * axisSign.z));
|
|
1036
|
+
return normalize(normalX * weights.x + normalY * weights.y + normalZ * weights.z);
|
|
1037
|
+
}
|
|
1038
|
+
#endif
|
|
1039
|
+
` +
|
|
1040
|
+
shader.fragmentShader
|
|
1041
|
+
.replace(
|
|
1042
|
+
'#include <map_fragment>',
|
|
1043
|
+
`#ifdef USE_MAP
|
|
1044
|
+
diffuseColor *= themeSampleSurfaceMap(map, vMapUv);
|
|
1045
|
+
#endif`,
|
|
1046
|
+
)
|
|
1047
|
+
.replace(
|
|
1048
|
+
'#include <roughnessmap_fragment>',
|
|
1049
|
+
`float roughnessFactor = roughness;
|
|
1050
|
+
#ifdef USE_ROUGHNESSMAP
|
|
1051
|
+
roughnessFactor *= themeSampleSurfaceResponseMap(roughnessMap, vRoughnessMapUv).g;
|
|
1052
|
+
#endif`,
|
|
1053
|
+
)
|
|
1054
|
+
.replace(
|
|
1055
|
+
'#include <metalnessmap_fragment>',
|
|
1056
|
+
`float metalnessFactor = metalness;
|
|
1057
|
+
#ifdef USE_METALNESSMAP
|
|
1058
|
+
metalnessFactor *= themeSampleSurfaceResponseMap(metalnessMap, vMetalnessMapUv).b;
|
|
1059
|
+
#endif`,
|
|
1060
|
+
)
|
|
1061
|
+
.replace(
|
|
1062
|
+
'#include <normal_fragment_maps>',
|
|
1063
|
+
`#ifdef USE_NORMALMAP_OBJECTSPACE
|
|
1064
|
+
vec3 mapN = themeSampleSurfaceMap(normalMap, vNormalMapUv).xyz * 2.0 - 1.0;
|
|
1065
|
+
#ifdef FLIP_SIDED
|
|
1066
|
+
mapN = -mapN;
|
|
1067
|
+
#endif
|
|
1068
|
+
#ifdef DOUBLE_SIDED
|
|
1069
|
+
mapN = mapN * faceDirection;
|
|
1070
|
+
#endif
|
|
1071
|
+
normal = normalize(normalMatrix * mapN);
|
|
1072
|
+
#elif defined(USE_NORMALMAP_TANGENTSPACE)
|
|
1073
|
+
if (vThemeMetricUv.z > 0.5) {
|
|
1074
|
+
vec3 mapN = texture2D(normalMap, themeOrientedSurfaceUv(vNormalMapUv)).xyz * 2.0 - 1.0;
|
|
1075
|
+
#if defined(USE_PACKED_NORMALMAP)
|
|
1076
|
+
mapN = vec3(mapN.xy, sqrt(saturate(1.0 - dot(mapN.xy, mapN.xy))));
|
|
1077
|
+
#endif
|
|
1078
|
+
mapN.xy *= normalScale;
|
|
1079
|
+
normal = normalize(tbn * mapN);
|
|
1080
|
+
} else {
|
|
1081
|
+
vec3 themeMetricNormalSample = themeSampleMetricNormal(normalMap, normalScale);
|
|
1082
|
+
normal = normalize(
|
|
1083
|
+
vThemeMetricAxisX * themeMetricNormalSample.x +
|
|
1084
|
+
vThemeMetricAxisY * themeMetricNormalSample.y +
|
|
1085
|
+
vThemeMetricAxisZ * themeMetricNormalSample.z
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
#elif defined(USE_BUMPMAP)
|
|
1089
|
+
normal = perturbNormalArb(-vViewPosition, normal, dHdxy_fwd(), faceDirection);
|
|
1090
|
+
#endif`,
|
|
1091
|
+
)
|
|
1092
|
+
.replace(
|
|
1093
|
+
'#include <aomap_fragment>',
|
|
1094
|
+
`#ifdef USE_AOMAP
|
|
1095
|
+
float ambientOcclusion =
|
|
1096
|
+
(themeSampleSurfaceResponseMap(aoMap, vAoMapUv).r - 1.0) * aoMapIntensity + 1.0;
|
|
1097
|
+
reflectedLight.indirectDiffuse *= ambientOcclusion;
|
|
1098
|
+
#if defined(USE_CLEARCOAT)
|
|
1099
|
+
clearcoatSpecularIndirect *= ambientOcclusion;
|
|
1100
|
+
#endif
|
|
1101
|
+
#if defined(USE_SHEEN)
|
|
1102
|
+
sheenSpecularIndirect *= ambientOcclusion;
|
|
1103
|
+
#endif
|
|
1104
|
+
#if defined(USE_ENVMAP) && defined(STANDARD)
|
|
1105
|
+
float dotNV = saturate(dot(geometryNormal, geometryViewDir));
|
|
1106
|
+
reflectedLight.indirectSpecular *=
|
|
1107
|
+
computeSpecularOcclusion(dotNV, ambientOcclusion, material.roughness);
|
|
1108
|
+
#endif
|
|
1109
|
+
#endif`,
|
|
1110
|
+
)
|
|
1111
|
+
.replace(
|
|
1112
|
+
'#include <emissivemap_fragment>',
|
|
1113
|
+
`#ifdef USE_EMISSIVEMAP
|
|
1114
|
+
vec4 emissiveColor = themeSampleSurfaceResponseMap(emissiveMap, vEmissiveMapUv);
|
|
1115
|
+
// Ribbon floors are a single shell, so their top, sides, caps, and underside
|
|
1116
|
+
// share one material. Keep guide-light artwork on the traversable face instead
|
|
1117
|
+
// of wrapping it around the shell like a glowing net.
|
|
1118
|
+
float themeEmissiveSurface = ${emissiveSurface === 'upward' ? 'smoothstep(0.08, 0.36, normalize(vThemeMetricNormal).y)' : '1.0'};
|
|
1119
|
+
totalEmissiveRadiance *= emissiveColor.rgb * themeEmissiveSurface;
|
|
1120
|
+
#endif`,
|
|
1121
|
+
)
|
|
1122
|
+
.replace(
|
|
1123
|
+
'#include <bumpmap_pars_fragment>',
|
|
1124
|
+
`#ifdef USE_BUMPMAP
|
|
1125
|
+
uniform sampler2D bumpMap;
|
|
1126
|
+
uniform float bumpScale;
|
|
1127
|
+
vec2 dHdxy_fwd() {
|
|
1128
|
+
if (vThemeMetricUv.z > 0.5) {
|
|
1129
|
+
vec2 mappedUv = themeOrientedSurfaceUv(vBumpMapUv);
|
|
1130
|
+
vec2 dSTdx = dFdx(mappedUv);
|
|
1131
|
+
vec2 dSTdy = dFdy(mappedUv);
|
|
1132
|
+
float Hll = bumpScale * texture2D(bumpMap, mappedUv).x;
|
|
1133
|
+
float dBx = bumpScale * texture2D(bumpMap, mappedUv + dSTdx).x - Hll;
|
|
1134
|
+
float dBy = bumpScale * texture2D(bumpMap, mappedUv + dSTdy).x - Hll;
|
|
1135
|
+
return vec2(dBx, dBy);
|
|
1136
|
+
}
|
|
1137
|
+
float metricHeight = bumpScale * themeSampleSurfaceMap(bumpMap, vBumpMapUv).x;
|
|
1138
|
+
return vec2(dFdx(metricHeight), dFdy(metricHeight));
|
|
1139
|
+
}
|
|
1140
|
+
vec3 perturbNormalArb(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) {
|
|
1141
|
+
vec3 vSigmaX = normalize(dFdx(surf_pos.xyz));
|
|
1142
|
+
vec3 vSigmaY = normalize(dFdy(surf_pos.xyz));
|
|
1143
|
+
vec3 R1 = cross(vSigmaY, surf_norm);
|
|
1144
|
+
vec3 R2 = cross(surf_norm, vSigmaX);
|
|
1145
|
+
float fDet = dot(vSigmaX, R1) * faceDirection;
|
|
1146
|
+
vec3 vGrad = sign(fDet) * (dHdxy.x * R1 + dHdxy.y * R2);
|
|
1147
|
+
return normalize(abs(fDet) * surf_norm - vGrad);
|
|
1148
|
+
}
|
|
1149
|
+
#endif`,
|
|
1150
|
+
)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function applyMetricMapShader(
|
|
1154
|
+
material: THREE.Material,
|
|
1155
|
+
worldSize: readonly [number, number],
|
|
1156
|
+
visualRelief?: MaterialRecipe['visualRelief'],
|
|
1157
|
+
emissiveSurface: MaterialRecipe['emissiveSurface'] = 'all',
|
|
1158
|
+
): void {
|
|
1159
|
+
configureThemeMetricAttribute(material)
|
|
1160
|
+
material.onBeforeCompile = shader => {
|
|
1161
|
+
injectMetricMapShader(
|
|
1162
|
+
shader,
|
|
1163
|
+
worldSize,
|
|
1164
|
+
visualRelief,
|
|
1165
|
+
material instanceof THREE.MeshStandardMaterial ? material.bumpMap : null,
|
|
1166
|
+
emissiveSurface,
|
|
1167
|
+
)
|
|
1168
|
+
}
|
|
1169
|
+
material.customProgramCacheKey = () =>
|
|
1170
|
+
`metric-map-v5:${worldSize[0].toFixed(4)}:${worldSize[1].toFixed(4)}:${visualRelief ? 'relief' : 'flat'}:${emissiveSurface}`
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function createToonGradientTexture(steps: number): THREE.DataTexture {
|
|
1174
|
+
const clampedSteps = THREE.MathUtils.clamp(Math.round(steps), 2, 6)
|
|
1175
|
+
const data = new Uint8Array(clampedSteps)
|
|
1176
|
+
for (let index = 0; index < clampedSteps; index += 1) {
|
|
1177
|
+
const progress = index / Math.max(1, clampedSteps - 1)
|
|
1178
|
+
data[index] = Math.round(THREE.MathUtils.lerp(184, 255, Math.pow(progress, 0.82)))
|
|
1179
|
+
}
|
|
1180
|
+
const texture = new THREE.DataTexture(data, clampedSteps, 1, THREE.RedFormat)
|
|
1181
|
+
texture.name = `theme-toon-gradient:${clampedSteps}`
|
|
1182
|
+
texture.minFilter = THREE.NearestFilter
|
|
1183
|
+
texture.magFilter = THREE.NearestFilter
|
|
1184
|
+
texture.generateMipmaps = false
|
|
1185
|
+
texture.needsUpdate = true
|
|
1186
|
+
return texture
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function applyToonInk(
|
|
1190
|
+
material: THREE.MeshToonMaterial,
|
|
1191
|
+
recipe: Extract<MaterialRecipe, { kind: 'toon' }>,
|
|
1192
|
+
mapWorldSize?: readonly [number, number],
|
|
1193
|
+
): void {
|
|
1194
|
+
const inkColor = recipe.inkColor ?? '#29352a'
|
|
1195
|
+
const inkStrength = THREE.MathUtils.clamp(recipe.inkStrength ?? 0.18, 0, 0.45)
|
|
1196
|
+
if (mapWorldSize) configureThemeMetricAttribute(material)
|
|
1197
|
+
material.onBeforeCompile = shader => {
|
|
1198
|
+
if (mapWorldSize) injectMetricMapShader(shader, mapWorldSize)
|
|
1199
|
+
shader.uniforms.themeInkColor = { value: new THREE.Color(inkColor) }
|
|
1200
|
+
shader.uniforms.themeInkStrength = { value: inkStrength }
|
|
1201
|
+
shader.fragmentShader =
|
|
1202
|
+
'uniform vec3 themeInkColor;\nuniform float themeInkStrength;\n' +
|
|
1203
|
+
shader.fragmentShader.replace(
|
|
1204
|
+
'#include <opaque_fragment>',
|
|
1205
|
+
`float themeFacing = abs(dot(normalize(normal), normalize(vViewPosition)));
|
|
1206
|
+
float themeInk = smoothstep(0.62, 0.96, 1.0 - themeFacing) * themeInkStrength;
|
|
1207
|
+
outgoingLight = mix(outgoingLight, themeInkColor, themeInk);
|
|
1208
|
+
#include <opaque_fragment>`,
|
|
1209
|
+
)
|
|
1210
|
+
}
|
|
1211
|
+
material.customProgramCacheKey = () =>
|
|
1212
|
+
`painted-toon:${inkColor}:${inkStrength.toFixed(3)}:${mapWorldSize?.join(':') ?? 'solid'}`
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function applyNeonNetShader(
|
|
1216
|
+
material: THREE.MeshStandardMaterial,
|
|
1217
|
+
spacing: number,
|
|
1218
|
+
lineWidth: number,
|
|
1219
|
+
fillOpacity: number,
|
|
1220
|
+
fillColor: string,
|
|
1221
|
+
): void {
|
|
1222
|
+
const safeSpacing = Math.max(0.001, spacing)
|
|
1223
|
+
const safeLineWidth = THREE.MathUtils.clamp(lineWidth, 0.015, 0.2)
|
|
1224
|
+
const safeFillOpacity = THREE.MathUtils.clamp(fillOpacity, 0, 0.95)
|
|
1225
|
+
const textureSignature = safeLineWidth.toFixed(4)
|
|
1226
|
+
if (material.userData.themeNeonNetTextureSignature !== textureSignature) {
|
|
1227
|
+
disposeNeonNetTexture(material)
|
|
1228
|
+
material.userData.themeNeonNetTexture = createNeonNetTexture(safeLineWidth)
|
|
1229
|
+
material.userData.themeNeonNetTextureSignature = textureSignature
|
|
1230
|
+
}
|
|
1231
|
+
const neonNetTexture = material.userData.themeNeonNetTexture as THREE.DataTexture
|
|
1232
|
+
const materialWithAttributeDefaults = material as THREE.MeshStandardMaterial & {
|
|
1233
|
+
defaultAttributeValues?: Record<string, number[]>
|
|
1234
|
+
}
|
|
1235
|
+
materialWithAttributeDefaults.defaultAttributeValues = {
|
|
1236
|
+
...materialWithAttributeDefaults.defaultAttributeValues,
|
|
1237
|
+
themeNetUv: [0, 0, 0, 0],
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
material.onBeforeCompile = shader => {
|
|
1241
|
+
shader.uniforms.themeNeonNetMap = { value: neonNetTexture }
|
|
1242
|
+
shader.uniforms.themeNeonNetFillOpacity = { value: safeFillOpacity }
|
|
1243
|
+
shader.uniforms.themeNeonNetFillColor = { value: new THREE.Color(fillColor) }
|
|
1244
|
+
shader.vertexShader =
|
|
1245
|
+
'attribute vec4 themeNetUv;\nvarying vec4 vThemeNetUv;\nvarying vec3 vThemeLocalPosition;\nvarying vec3 vThemeLocalNormal;\n' +
|
|
1246
|
+
shader.vertexShader.replace(
|
|
1247
|
+
'#include <project_vertex>',
|
|
1248
|
+
`#include <project_vertex>
|
|
1249
|
+
vThemeNetUv = themeNetUv;
|
|
1250
|
+
vec3 themeNetPosition = transformed;
|
|
1251
|
+
vec3 themeNetNormal = objectNormal;
|
|
1252
|
+
#ifdef USE_INSTANCING
|
|
1253
|
+
themeNetPosition = (instanceMatrix * vec4(themeNetPosition, 1.0)).xyz;
|
|
1254
|
+
mat3 themeNetInstanceNormalMatrix = mat3(instanceMatrix);
|
|
1255
|
+
themeNetNormal /= vec3(
|
|
1256
|
+
dot(themeNetInstanceNormalMatrix[0], themeNetInstanceNormalMatrix[0]),
|
|
1257
|
+
dot(themeNetInstanceNormalMatrix[1], themeNetInstanceNormalMatrix[1]),
|
|
1258
|
+
dot(themeNetInstanceNormalMatrix[2], themeNetInstanceNormalMatrix[2])
|
|
1259
|
+
);
|
|
1260
|
+
themeNetNormal = themeNetInstanceNormalMatrix * themeNetNormal;
|
|
1261
|
+
#endif
|
|
1262
|
+
vThemeLocalPosition = themeNetPosition;
|
|
1263
|
+
vThemeLocalNormal = normalize(themeNetNormal);`,
|
|
1264
|
+
)
|
|
1265
|
+
shader.fragmentShader =
|
|
1266
|
+
'uniform sampler2D themeNeonNetMap;\nuniform float themeNeonNetFillOpacity;\nuniform vec3 themeNeonNetFillColor;\nvarying vec4 vThemeNetUv;\nvarying vec3 vThemeLocalPosition;\nvarying vec3 vThemeLocalNormal;\n' +
|
|
1267
|
+
shader.fragmentShader
|
|
1268
|
+
.replace(
|
|
1269
|
+
'#include <alphatest_fragment>',
|
|
1270
|
+
`vec3 themeNormal = abs(normalize(vThemeLocalNormal));
|
|
1271
|
+
vec2 themeNetCoordinate;
|
|
1272
|
+
if (vThemeNetUv.z > 0.5) {
|
|
1273
|
+
float themeNetLength = max(vThemeNetUv.w, ${safeSpacing.toFixed(6)});
|
|
1274
|
+
float themeNetCycles = max(1.0, floor(themeNetLength / ${safeSpacing.toFixed(6)} + 0.5));
|
|
1275
|
+
float themeNetLockedLength = themeNetCycles * ${safeSpacing.toFixed(6)};
|
|
1276
|
+
themeNetCoordinate = vec2(
|
|
1277
|
+
vThemeNetUv.x,
|
|
1278
|
+
vThemeNetUv.y * themeNetLockedLength / themeNetLength
|
|
1279
|
+
);
|
|
1280
|
+
} else {
|
|
1281
|
+
themeNetCoordinate =
|
|
1282
|
+
themeNormal.y >= themeNormal.x && themeNormal.y >= themeNormal.z
|
|
1283
|
+
? vThemeLocalPosition.xz
|
|
1284
|
+
: themeNormal.x >= themeNormal.z
|
|
1285
|
+
? vThemeLocalPosition.zy
|
|
1286
|
+
: vThemeLocalPosition.xy;
|
|
1287
|
+
}
|
|
1288
|
+
vec2 themeNetCycle = themeNetCoordinate / ${safeSpacing.toFixed(6)};
|
|
1289
|
+
float themeNetMask = texture2D(themeNeonNetMap, themeNetCycle).a;
|
|
1290
|
+
diffuseColor.rgb = mix(themeNeonNetFillColor, diffuseColor.rgb, themeNetMask);
|
|
1291
|
+
diffuseColor.a *= mix(themeNeonNetFillOpacity, 1.0, themeNetMask);
|
|
1292
|
+
#include <alphatest_fragment>`,
|
|
1293
|
+
)
|
|
1294
|
+
.replace(
|
|
1295
|
+
'#include <emissivemap_fragment>',
|
|
1296
|
+
`#include <emissivemap_fragment>
|
|
1297
|
+
totalEmissiveRadiance *= themeNetMask;`,
|
|
1298
|
+
)
|
|
1299
|
+
}
|
|
1300
|
+
material.customProgramCacheKey = () => `neon-net-v8:${safeSpacing.toFixed(6)}:${safeLineWidth.toFixed(4)}`
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function createNeonNetTexture(lineWidth: number): THREE.DataTexture {
|
|
1304
|
+
const size = 128
|
|
1305
|
+
const samplesPerAxis = 4
|
|
1306
|
+
const data = new Uint8Array(size * size * 4)
|
|
1307
|
+
|
|
1308
|
+
for (let y = 0; y < size; y += 1) {
|
|
1309
|
+
for (let x = 0; x < size; x += 1) {
|
|
1310
|
+
let coveredSamples = 0
|
|
1311
|
+
for (let sampleY = 0; sampleY < samplesPerAxis; sampleY += 1) {
|
|
1312
|
+
for (let sampleX = 0; sampleX < samplesPerAxis; sampleX += 1) {
|
|
1313
|
+
const unitX = (x + (sampleX + 0.5) / samplesPerAxis) / size
|
|
1314
|
+
const unitY = (y + (sampleY + 0.5) / samplesPerAxis) / size
|
|
1315
|
+
const distanceToVerticalLine = Math.min(unitX, 1 - unitX)
|
|
1316
|
+
const distanceToHorizontalLine = Math.min(unitY, 1 - unitY)
|
|
1317
|
+
if (distanceToVerticalLine <= lineWidth / 2 || distanceToHorizontalLine <= lineWidth / 2) {
|
|
1318
|
+
coveredSamples += 1
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const offset = (y * size + x) * 4
|
|
1324
|
+
data[offset] = 255
|
|
1325
|
+
data[offset + 1] = 255
|
|
1326
|
+
data[offset + 2] = 255
|
|
1327
|
+
data[offset + 3] = Math.round((coveredSamples / (samplesPerAxis * samplesPerAxis)) * 255)
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
|
|
1332
|
+
texture.name = `theme-pattern:neon-net:${lineWidth.toFixed(4)}`
|
|
1333
|
+
texture.wrapS = THREE.RepeatWrapping
|
|
1334
|
+
texture.wrapT = THREE.RepeatWrapping
|
|
1335
|
+
texture.magFilter = THREE.LinearFilter
|
|
1336
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter
|
|
1337
|
+
texture.generateMipmaps = true
|
|
1338
|
+
texture.needsUpdate = true
|
|
1339
|
+
return texture
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function disposeNeonNetTexture(material: THREE.Material): void {
|
|
1343
|
+
const texture = material.userData.themeNeonNetTexture
|
|
1344
|
+
if (texture instanceof THREE.Texture) texture.dispose()
|
|
1345
|
+
delete material.userData.themeNeonNetTexture
|
|
1346
|
+
delete material.userData.themeNeonNetTextureSignature
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function disposeWeatheredMetalResponseTextures(material: THREE.Material): void {
|
|
1350
|
+
const response = material.userData.themeWeatheredMetalResponse as
|
|
1351
|
+
{ height?: THREE.Texture; roughness?: THREE.Texture; metalness?: THREE.Texture } | undefined
|
|
1352
|
+
response?.height?.dispose()
|
|
1353
|
+
response?.roughness?.dispose()
|
|
1354
|
+
response?.metalness?.dispose()
|
|
1355
|
+
if (material instanceof THREE.MeshStandardMaterial) {
|
|
1356
|
+
material.bumpMap = null
|
|
1357
|
+
material.roughnessMap = null
|
|
1358
|
+
material.metalnessMap = null
|
|
1359
|
+
}
|
|
1360
|
+
delete material.userData.themeWeatheredMetalResponse
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function assignMaterial(mesh: THREE.Mesh, material: THREE.Material, materialIndex?: number): void {
|
|
1364
|
+
if (materialIndex === undefined) {
|
|
1365
|
+
mesh.material = material
|
|
1366
|
+
return
|
|
1367
|
+
}
|
|
1368
|
+
const materials = Array.isArray(mesh.material) ? [...mesh.material] : [mesh.material]
|
|
1369
|
+
materials[materialIndex] = material
|
|
1370
|
+
mesh.material = materials
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function applyThemeVisibility(object: THREE.Object3D, themeId: string): void {
|
|
1374
|
+
const themeHidden = object.userData.themeHidden
|
|
1375
|
+
if (typeof themeHidden === 'string' && themeHidden === themeId) {
|
|
1376
|
+
object.visible = false
|
|
1377
|
+
return
|
|
1378
|
+
}
|
|
1379
|
+
if (Array.isArray(themeHidden) && themeHidden.includes(themeId)) {
|
|
1380
|
+
object.visible = false
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1383
|
+
const themeOnly = object.userData.themeOnly
|
|
1384
|
+
if (typeof themeOnly === 'string') {
|
|
1385
|
+
object.visible = themeOnly === themeId
|
|
1386
|
+
return
|
|
1387
|
+
}
|
|
1388
|
+
if (Array.isArray(themeOnly)) {
|
|
1389
|
+
object.visible = themeOnly.includes(themeId)
|
|
1390
|
+
return
|
|
1391
|
+
}
|
|
1392
|
+
object.visible = true
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function hasThemeVisibility(object: THREE.Object3D): boolean {
|
|
1396
|
+
return (
|
|
1397
|
+
typeof object.userData.themeOnly === 'string' ||
|
|
1398
|
+
Array.isArray(object.userData.themeOnly) ||
|
|
1399
|
+
typeof object.userData.themeHidden === 'string' ||
|
|
1400
|
+
Array.isArray(object.userData.themeHidden)
|
|
1401
|
+
)
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function collectTextures(material: THREE.Material, textures: Set<THREE.Texture>): void {
|
|
1405
|
+
for (const value of Object.values(material)) {
|
|
1406
|
+
if (value instanceof THREE.Texture) textures.add(value)
|
|
1407
|
+
}
|
|
1408
|
+
const neonNetTexture = material.userData.themeNeonNetTexture
|
|
1409
|
+
if (neonNetTexture instanceof THREE.Texture) textures.add(neonNetTexture)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function patternSignature(pattern: MaterialPatternRecipe | undefined): string {
|
|
1413
|
+
return pattern
|
|
1414
|
+
? `${pattern.kind}:${pattern.primary}:${pattern.secondary}:${pattern.worldSize[0]}:${pattern.worldSize[1]}`
|
|
1415
|
+
: 'none'
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function mapSignature(recipe: MaterialRecipe): string {
|
|
1419
|
+
const worldSize = materialTextureWorldSize(recipe)
|
|
1420
|
+
return [
|
|
1421
|
+
patternSignature(recipe.pattern),
|
|
1422
|
+
recipe.textureUrl ?? 'procedural',
|
|
1423
|
+
recipe.emissiveTextureUrl ?? 'no-emissive',
|
|
1424
|
+
recipe.emissiveSurface ?? 'all-emissive-surfaces',
|
|
1425
|
+
recipe.normalTextureUrl ?? 'no-normal',
|
|
1426
|
+
recipe.heightTextureUrl ?? 'no-height',
|
|
1427
|
+
recipe.roughnessTextureUrl ?? 'no-roughness',
|
|
1428
|
+
recipe.aoTextureUrl ?? 'no-ao',
|
|
1429
|
+
worldSize[0],
|
|
1430
|
+
worldSize[1],
|
|
1431
|
+
recipe.visualRelief?.scale ?? 'no-relief',
|
|
1432
|
+
recipe.visualRelief?.bias ?? 'no-relief-bias',
|
|
1433
|
+
recipe.visualRelief?.targetEdgeLength ?? 'no-relief-edge',
|
|
1434
|
+
].join(':')
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function materialTextureWorldSize(recipe: MaterialRecipe): readonly [number, number] {
|
|
1438
|
+
return recipe.textureWorldSize ?? recipe.pattern?.worldSize ?? [2, 2]
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* The browser uses the authored image scale while server-side tests and other
|
|
1443
|
+
* non-DOM renderers use the procedural pattern fallback. Keep the shader scale
|
|
1444
|
+
* paired with the texture that was actually created; otherwise albedo repeats
|
|
1445
|
+
* at one physical size while the metric shader samples it at another.
|
|
1446
|
+
*/
|
|
1447
|
+
function activeMaterialTextureWorldSize(recipe: MaterialRecipe): readonly [number, number] {
|
|
1448
|
+
if (recipe.textureUrl && typeof document === 'undefined' && recipe.pattern) {
|
|
1449
|
+
return recipe.pattern.worldSize
|
|
1450
|
+
}
|
|
1451
|
+
return materialTextureWorldSize(recipe)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function createRecipeTexture(recipe: MaterialRecipe): THREE.Texture | null {
|
|
1455
|
+
if (recipe.textureUrl && typeof document !== 'undefined') {
|
|
1456
|
+
const worldSize = materialTextureWorldSize(recipe)
|
|
1457
|
+
const texture = createThemeMaterialTexture(
|
|
1458
|
+
recipe.textureUrl,
|
|
1459
|
+
`albedo:${worldSize[0]}:${worldSize[1]}`,
|
|
1460
|
+
)
|
|
1461
|
+
texture.name = `theme-texture:${recipe.textureUrl}`
|
|
1462
|
+
texture.wrapS = THREE.RepeatWrapping
|
|
1463
|
+
texture.wrapT = THREE.RepeatWrapping
|
|
1464
|
+
texture.repeat.set(1 / Math.max(0.001, worldSize[0]), 1 / Math.max(0.001, worldSize[1]))
|
|
1465
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
1466
|
+
texture.magFilter = THREE.LinearFilter
|
|
1467
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter
|
|
1468
|
+
texture.generateMipmaps = true
|
|
1469
|
+
texture.anisotropy = 4
|
|
1470
|
+
return texture
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
return recipe.pattern && recipe.pattern.kind !== 'neon-net' && recipe.pattern.kind !== 'holographic-hover-grid'
|
|
1474
|
+
? createPatternTexture(recipe.pattern)
|
|
1475
|
+
: null
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function createRecipeResponseTexture(
|
|
1479
|
+
url: string | undefined,
|
|
1480
|
+
recipe: MaterialRecipe,
|
|
1481
|
+
role: 'normal' | 'height' | 'roughness' | 'ao' | 'emissive',
|
|
1482
|
+
): THREE.Texture | null {
|
|
1483
|
+
if (!url || typeof document === 'undefined') return null
|
|
1484
|
+
const worldSize = materialTextureWorldSize(recipe)
|
|
1485
|
+
const texture = createThemeMaterialTexture(
|
|
1486
|
+
url,
|
|
1487
|
+
`${role}:${worldSize[0]}:${worldSize[1]}`,
|
|
1488
|
+
)
|
|
1489
|
+
texture.name = `theme-${role}-texture:${url}`
|
|
1490
|
+
texture.wrapS = THREE.RepeatWrapping
|
|
1491
|
+
texture.wrapT = THREE.RepeatWrapping
|
|
1492
|
+
texture.repeat.set(1 / Math.max(0.001, worldSize[0]), 1 / Math.max(0.001, worldSize[1]))
|
|
1493
|
+
texture.colorSpace = role === 'emissive' ? THREE.SRGBColorSpace : THREE.NoColorSpace
|
|
1494
|
+
texture.magFilter = THREE.LinearFilter
|
|
1495
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter
|
|
1496
|
+
texture.generateMipmaps = true
|
|
1497
|
+
texture.anisotropy = 4
|
|
1498
|
+
if (role === 'ao') texture.channel = 0
|
|
1499
|
+
return texture
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function createPatternTexture(pattern: MaterialPatternRecipe): THREE.DataTexture {
|
|
1503
|
+
const size =
|
|
1504
|
+
pattern.kind === 'railroad-planks' || pattern.kind === 'bridge-planks' || pattern.kind === 'weathered-sheet-metal'
|
|
1505
|
+
? 256
|
|
1506
|
+
: pattern.kind === 'fence-timber'
|
|
1507
|
+
? 128
|
|
1508
|
+
: 64
|
|
1509
|
+
const data = new Uint8Array(size * size * 4)
|
|
1510
|
+
const primary = new THREE.Color(pattern.primary)
|
|
1511
|
+
const secondary = new THREE.Color(pattern.secondary)
|
|
1512
|
+
const iron = new THREE.Color('#343338')
|
|
1513
|
+
const ironHighlight = new THREE.Color('#c0b7a6')
|
|
1514
|
+
|
|
1515
|
+
for (let y = 0; y < size; y += 1) {
|
|
1516
|
+
for (let x = 0; x < size; x += 1) {
|
|
1517
|
+
if (pattern.kind === 'railroad-planks' || pattern.kind === 'bridge-planks') {
|
|
1518
|
+
writeBridgePlankPixel(
|
|
1519
|
+
data,
|
|
1520
|
+
(y * size + x) * 4,
|
|
1521
|
+
x,
|
|
1522
|
+
y,
|
|
1523
|
+
size,
|
|
1524
|
+
primary,
|
|
1525
|
+
secondary,
|
|
1526
|
+
iron,
|
|
1527
|
+
ironHighlight,
|
|
1528
|
+
pattern.kind === 'railroad-planks',
|
|
1529
|
+
)
|
|
1530
|
+
continue
|
|
1531
|
+
}
|
|
1532
|
+
if (pattern.kind === 'weathered-sheet-metal') {
|
|
1533
|
+
writeWeatheredSheetMetalPixel(data, (y * size + x) * 4, x, y, size, primary, secondary)
|
|
1534
|
+
continue
|
|
1535
|
+
}
|
|
1536
|
+
const value = patternValue(pattern.kind, x, y, size)
|
|
1537
|
+
const color = primary.clone().lerp(secondary, value)
|
|
1538
|
+
if (pattern.kind === 'fence-timber') color.convertLinearToSRGB()
|
|
1539
|
+
const offset = (y * size + x) * 4
|
|
1540
|
+
data[offset] = Math.round(color.r * 255)
|
|
1541
|
+
data[offset + 1] = Math.round(color.g * 255)
|
|
1542
|
+
data[offset + 2] = Math.round(color.b * 255)
|
|
1543
|
+
data[offset + 3] = pattern.kind === 'neon-net' && value < 0.5 ? 0 : 255
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
|
|
1548
|
+
texture.name = `theme-pattern:${pattern.kind}`
|
|
1549
|
+
texture.wrapS = THREE.RepeatWrapping
|
|
1550
|
+
texture.wrapT = THREE.RepeatWrapping
|
|
1551
|
+
texture.repeat.set(1 / Math.max(0.001, pattern.worldSize[0]), 1 / Math.max(0.001, pattern.worldSize[1]))
|
|
1552
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
1553
|
+
texture.magFilter = pattern.kind === 'neon-net' ? THREE.NearestFilter : THREE.LinearFilter
|
|
1554
|
+
texture.minFilter = pattern.kind === 'neon-net' ? THREE.NearestFilter : THREE.LinearMipmapLinearFilter
|
|
1555
|
+
texture.generateMipmaps = pattern.kind !== 'neon-net'
|
|
1556
|
+
texture.needsUpdate = true
|
|
1557
|
+
return texture
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
interface WeatheredMetalSample {
|
|
1561
|
+
seam: number
|
|
1562
|
+
rivet: number
|
|
1563
|
+
rust: number
|
|
1564
|
+
scratch: number
|
|
1565
|
+
variation: number
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function weatheredMetalSample(x: number, y: number, size: number): WeatheredMetalSample {
|
|
1569
|
+
const unitX = x / size
|
|
1570
|
+
const unitY = y / size
|
|
1571
|
+
const edgeX = Math.min(unitX, 1 - unitX)
|
|
1572
|
+
const edgeY = Math.min(unitY, 1 - unitY)
|
|
1573
|
+
const seamDistance = Math.min(edgeX, edgeY)
|
|
1574
|
+
const seam = 1 - THREE.MathUtils.smoothstep(seamDistance, 0.012, 0.036)
|
|
1575
|
+
const rivetCenters = [0.075, 0.925]
|
|
1576
|
+
let rivet = 0
|
|
1577
|
+
for (const centerX of rivetCenters) {
|
|
1578
|
+
for (const centerY of rivetCenters) {
|
|
1579
|
+
const distance = Math.hypot(unitX - centerX, unitY - centerY)
|
|
1580
|
+
rivet = Math.max(rivet, 1 - THREE.MathUtils.smoothstep(distance, 0.018, 0.035))
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const coarse = pseudoNoise(Math.floor(x / 7) * 11 + 5, Math.floor(y / 7) * 13 + 9)
|
|
1584
|
+
const fine = pseudoNoise(x * 5 + 19, y * 7 + 31)
|
|
1585
|
+
const seamOxidation = seam * (0.24 + pseudoNoise(x * 3 + 7, y * 2 + 17) * 0.62)
|
|
1586
|
+
const patch = coarse > 0.69 ? THREE.MathUtils.smoothstep(coarse, 0.69, 0.94) : 0
|
|
1587
|
+
const rust = THREE.MathUtils.clamp(seamOxidation * 0.82 + patch * 0.76 + (fine > 0.975 ? 0.5 : 0), 0, 1)
|
|
1588
|
+
const diagonal = Math.abs(((unitX * 2.7 + unitY * 0.42 + 0.21) % 1) - 0.5)
|
|
1589
|
+
const scratch = diagonal < 0.006 || Math.abs(((unitX * 0.7 - unitY * 3.1 + 0.37) % 1) - 0.5) < 0.004 ? 1 : 0
|
|
1590
|
+
return {
|
|
1591
|
+
seam,
|
|
1592
|
+
rivet,
|
|
1593
|
+
rust,
|
|
1594
|
+
scratch,
|
|
1595
|
+
variation: pseudoNoise(x * 2 + 3, y * 5 + 11),
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function writeWeatheredSheetMetalPixel(
|
|
1600
|
+
data: Uint8Array,
|
|
1601
|
+
offset: number,
|
|
1602
|
+
x: number,
|
|
1603
|
+
y: number,
|
|
1604
|
+
size: number,
|
|
1605
|
+
steel: THREE.Color,
|
|
1606
|
+
oxide: THREE.Color,
|
|
1607
|
+
): void {
|
|
1608
|
+
const sample = weatheredMetalSample(x, y, size)
|
|
1609
|
+
const seamColor = new THREE.Color('#242728')
|
|
1610
|
+
const rivetColor = new THREE.Color('#d4dad7')
|
|
1611
|
+
const grime = 0.09 + sample.variation * 0.15
|
|
1612
|
+
const color = steel.clone().multiplyScalar(1 - grime)
|
|
1613
|
+
color.lerp(oxide, sample.rust * 0.88)
|
|
1614
|
+
color.lerp(seamColor, sample.seam * 0.72)
|
|
1615
|
+
color.lerp(rivetColor, sample.rivet * 0.94)
|
|
1616
|
+
if (sample.scratch > 0 && sample.rust < 0.5) color.lerp(rivetColor, 0.44)
|
|
1617
|
+
data[offset] = Math.round(THREE.MathUtils.clamp(color.r, 0, 1) * 255)
|
|
1618
|
+
data[offset + 1] = Math.round(THREE.MathUtils.clamp(color.g, 0, 1) * 255)
|
|
1619
|
+
data[offset + 2] = Math.round(THREE.MathUtils.clamp(color.b, 0, 1) * 255)
|
|
1620
|
+
data[offset + 3] = 255
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function createWeatheredMetalResponseTextures(pattern: MaterialPatternRecipe): {
|
|
1624
|
+
height: THREE.DataTexture
|
|
1625
|
+
roughness: THREE.DataTexture
|
|
1626
|
+
metalness: THREE.DataTexture
|
|
1627
|
+
} {
|
|
1628
|
+
const size = 256
|
|
1629
|
+
const height = new Uint8Array(size * size * 4)
|
|
1630
|
+
const roughness = new Uint8Array(size * size * 4)
|
|
1631
|
+
const metalness = new Uint8Array(size * size * 4)
|
|
1632
|
+
const writeScalar = (target: Uint8Array, pixel: number, value: number): void => {
|
|
1633
|
+
const offset = pixel * 4
|
|
1634
|
+
target[offset] = value
|
|
1635
|
+
target[offset + 1] = value
|
|
1636
|
+
target[offset + 2] = value
|
|
1637
|
+
target[offset + 3] = 255
|
|
1638
|
+
}
|
|
1639
|
+
for (let y = 0; y < size; y += 1) {
|
|
1640
|
+
for (let x = 0; x < size; x += 1) {
|
|
1641
|
+
const sample = weatheredMetalSample(x, y, size)
|
|
1642
|
+
const offset = y * size + x
|
|
1643
|
+
writeScalar(
|
|
1644
|
+
height,
|
|
1645
|
+
offset,
|
|
1646
|
+
Math.round(
|
|
1647
|
+
THREE.MathUtils.clamp(0.52 - sample.seam * 0.38 + sample.rivet * 0.48 + sample.rust * 0.08, 0, 1) * 255,
|
|
1648
|
+
),
|
|
1649
|
+
)
|
|
1650
|
+
writeScalar(
|
|
1651
|
+
roughness,
|
|
1652
|
+
offset,
|
|
1653
|
+
Math.round(
|
|
1654
|
+
THREE.MathUtils.clamp(0.38 + sample.variation * 0.18 + sample.rust * 0.48 - sample.rivet * 0.2, 0.08, 1) *
|
|
1655
|
+
255,
|
|
1656
|
+
),
|
|
1657
|
+
)
|
|
1658
|
+
writeScalar(
|
|
1659
|
+
metalness,
|
|
1660
|
+
offset,
|
|
1661
|
+
Math.round(
|
|
1662
|
+
THREE.MathUtils.clamp(0.96 - sample.rust * 0.9 - sample.seam * 0.12 + sample.rivet * 0.04, 0.03, 1) * 255,
|
|
1663
|
+
),
|
|
1664
|
+
)
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
const makeTexture = (data: Uint8Array, name: string): THREE.DataTexture => {
|
|
1668
|
+
// Three's roughness and metalness chunks read green and blue respectively,
|
|
1669
|
+
// so repeat the scalar into every RGB channel instead of using RedFormat.
|
|
1670
|
+
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
|
|
1671
|
+
texture.name = name
|
|
1672
|
+
texture.wrapS = THREE.RepeatWrapping
|
|
1673
|
+
texture.wrapT = THREE.RepeatWrapping
|
|
1674
|
+
texture.repeat.set(1 / Math.max(0.001, pattern.worldSize[0]), 1 / Math.max(0.001, pattern.worldSize[1]))
|
|
1675
|
+
texture.magFilter = THREE.LinearFilter
|
|
1676
|
+
texture.minFilter = THREE.LinearMipmapLinearFilter
|
|
1677
|
+
texture.generateMipmaps = true
|
|
1678
|
+
texture.needsUpdate = true
|
|
1679
|
+
return texture
|
|
1680
|
+
}
|
|
1681
|
+
return {
|
|
1682
|
+
height: makeTexture(height, 'theme-response:weathered-sheet-metal:height'),
|
|
1683
|
+
roughness: makeTexture(roughness, 'theme-response:weathered-sheet-metal:roughness'),
|
|
1684
|
+
metalness: makeTexture(metalness, 'theme-response:weathered-sheet-metal:metalness'),
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function writeBridgePlankPixel(
|
|
1689
|
+
data: Uint8Array,
|
|
1690
|
+
offset: number,
|
|
1691
|
+
x: number,
|
|
1692
|
+
y: number,
|
|
1693
|
+
size: number,
|
|
1694
|
+
timber: THREE.Color,
|
|
1695
|
+
darkTimber: THREE.Color,
|
|
1696
|
+
iron: THREE.Color,
|
|
1697
|
+
ironHighlight: THREE.Color,
|
|
1698
|
+
includeRailroad: boolean,
|
|
1699
|
+
): void {
|
|
1700
|
+
const unitX = x / size
|
|
1701
|
+
const unitY = y / size
|
|
1702
|
+
const rowCount = 8
|
|
1703
|
+
const row = Math.floor(unitY * rowCount)
|
|
1704
|
+
const rowProgress = (unitY * rowCount) % 1
|
|
1705
|
+
const centeredX = Math.min(unitX, 1 - unitX)
|
|
1706
|
+
const railDistance = Math.abs(centeredX - 0.15)
|
|
1707
|
+
const boardVariation = (pseudoNoise(row * 31 + 7, 13) - 0.5) * 0.16
|
|
1708
|
+
const broadGrain = Math.abs(Math.sin(unitX * 115 + Math.sin(unitY * 19) * 2.1))
|
|
1709
|
+
const fineGrain = Math.abs(Math.sin(unitX * 280 + unitY * 11))
|
|
1710
|
+
const seam = Math.min(rowProgress, 1 - rowProgress)
|
|
1711
|
+
const plankGap = seam < 0.035
|
|
1712
|
+
const edgeDarkening = seam < 0.075 ? 0.28 : 0
|
|
1713
|
+
const timberMix = THREE.MathUtils.clamp(
|
|
1714
|
+
0.08 + broadGrain * 0.13 + fineGrain * 0.045 + pseudoNoise(x, y) * 0.055 + boardVariation + edgeDarkening,
|
|
1715
|
+
0,
|
|
1716
|
+
0.92,
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
let red = THREE.MathUtils.lerp(timber.r, darkTimber.r, timberMix)
|
|
1720
|
+
let green = THREE.MathUtils.lerp(timber.g, darkTimber.g, timberMix)
|
|
1721
|
+
let blue = THREE.MathUtils.lerp(timber.b, darkTimber.b, timberMix)
|
|
1722
|
+
|
|
1723
|
+
if (includeRailroad && railDistance < 0.016) {
|
|
1724
|
+
const highlight = 1 - railDistance / 0.016
|
|
1725
|
+
const railColorMix = 0.14 + Math.pow(highlight, 2.4) * 0.86
|
|
1726
|
+
red = THREE.MathUtils.lerp(iron.r, ironHighlight.r, railColorMix)
|
|
1727
|
+
green = THREE.MathUtils.lerp(iron.g, ironHighlight.g, railColorMix)
|
|
1728
|
+
blue = THREE.MathUtils.lerp(iron.b, ironHighlight.b, railColorMix)
|
|
1729
|
+
} else if (includeRailroad && railDistance < 0.026) {
|
|
1730
|
+
red *= 0.58
|
|
1731
|
+
green *= 0.58
|
|
1732
|
+
blue *= 0.58
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
const spikeRow = Math.min(Math.abs(rowProgress - 0.22), Math.abs(rowProgress - 0.78)) < 0.027
|
|
1736
|
+
if (includeRailroad && spikeRow && railDistance >= 0.016 && railDistance < 0.045) {
|
|
1737
|
+
red = ironHighlight.r * 0.72
|
|
1738
|
+
green = ironHighlight.g * 0.72
|
|
1739
|
+
blue = ironHighlight.b * 0.72
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
data[offset] = Math.round(THREE.MathUtils.clamp(red, 0, 1) * 255)
|
|
1743
|
+
data[offset + 1] = Math.round(THREE.MathUtils.clamp(green, 0, 1) * 255)
|
|
1744
|
+
data[offset + 2] = Math.round(THREE.MathUtils.clamp(blue, 0, 1) * 255)
|
|
1745
|
+
data[offset + 3] = plankGap && (!includeRailroad || railDistance > 0.045) ? 0 : 255
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function patternValue(kind: MaterialPatternRecipe['kind'], x: number, y: number, size: number): number {
|
|
1749
|
+
const unitX = x / size
|
|
1750
|
+
const unitY = y / size
|
|
1751
|
+
const hash = pseudoNoise(x, y)
|
|
1752
|
+
|
|
1753
|
+
switch (kind) {
|
|
1754
|
+
case 'wood': {
|
|
1755
|
+
const grain = Math.sin(unitX * 46 + Math.sin(unitY * 12) * 2.2)
|
|
1756
|
+
const plank = y % 16 < 1 || x % 31 < 1 ? 0.82 : 0
|
|
1757
|
+
return THREE.MathUtils.clamp(0.12 + Math.abs(grain) * 0.18 + hash * 0.08 + plank, 0, 1)
|
|
1758
|
+
}
|
|
1759
|
+
case 'rough-wood': {
|
|
1760
|
+
const longWarp = Math.sin(unitY * 11 + Math.sin(unitY * 4.5) * 1.8)
|
|
1761
|
+
const broadGrain = Math.abs(Math.sin(unitX * 25 + longWarp * 2.8))
|
|
1762
|
+
const fineGrain = Math.abs(Math.sin(unitX * 63 + unitY * 6 + Math.sin(unitY * 18) * 1.6))
|
|
1763
|
+
const knotA = Math.hypot((unitX - 0.28) * 1.6, unitY - 0.3)
|
|
1764
|
+
const knotB = Math.hypot((unitX - 0.74) * 1.8, unitY - 0.76)
|
|
1765
|
+
const knotRings =
|
|
1766
|
+
Math.exp(-knotA * 12) * Math.abs(Math.sin(knotA * 82)) + Math.exp(-knotB * 13) * Math.abs(Math.sin(knotB * 94))
|
|
1767
|
+
const crackA = Math.abs(unitX - (0.16 + Math.sin(unitY * 17) * 0.022)) < 0.012 ? 0.58 : 0
|
|
1768
|
+
const crackB =
|
|
1769
|
+
unitY > 0.42 && unitY < 0.84 && Math.abs(unitX - (0.84 + Math.sin(unitY * 23 + 0.6) * 0.019)) < 0.01 ? 0.52 : 0
|
|
1770
|
+
return THREE.MathUtils.clamp(
|
|
1771
|
+
0.04 + broadGrain * 0.3 + fineGrain * 0.1 + knotRings * 0.62 + crackA + crackB + hash * 0.1,
|
|
1772
|
+
0,
|
|
1773
|
+
1,
|
|
1774
|
+
)
|
|
1775
|
+
}
|
|
1776
|
+
case 'railroad-planks':
|
|
1777
|
+
case 'bridge-planks':
|
|
1778
|
+
return 0
|
|
1779
|
+
case 'fence-timber': {
|
|
1780
|
+
const longGrain = Math.abs(Math.sin(unitX * 33 + Math.sin(unitY * 7.5) * 2.2))
|
|
1781
|
+
const fineGrain = Math.abs(Math.sin(unitX * 81 + unitY * 4.5))
|
|
1782
|
+
const knotA = Math.hypot((unitX - 0.27) * 2.2, unitY - 0.34)
|
|
1783
|
+
const knotB = Math.hypot((unitX - 0.73) * 2.4, unitY - 0.78)
|
|
1784
|
+
const knots =
|
|
1785
|
+
Math.exp(-knotA * 16) * Math.abs(Math.sin(knotA * 95)) + Math.exp(-knotB * 17) * Math.abs(Math.sin(knotB * 104))
|
|
1786
|
+
const sawVariation = Math.abs(Math.sin(unitY * 17 + unitX * 3.2)) * 0.035
|
|
1787
|
+
return THREE.MathUtils.clamp(
|
|
1788
|
+
0.045 + longGrain * 0.34 + fineGrain * 0.075 + knots * 0.62 + sawVariation + hash * 0.045,
|
|
1789
|
+
0,
|
|
1790
|
+
0.94,
|
|
1791
|
+
)
|
|
1792
|
+
}
|
|
1793
|
+
case 'braided-rope': {
|
|
1794
|
+
const diagonalA = Math.sin((unitX * 7.5 + unitY * 25) * Math.PI)
|
|
1795
|
+
const diagonalB = Math.sin((-unitX * 7.5 + unitY * 25) * Math.PI)
|
|
1796
|
+
const braid = Math.pow(Math.max(diagonalA, diagonalB) * 0.5 + 0.5, 2.2)
|
|
1797
|
+
const strandShadow = Math.abs(Math.sin(unitY * Math.PI * 50))
|
|
1798
|
+
return THREE.MathUtils.clamp(0.08 + braid * 0.62 + strandShadow * 0.13 + hash * 0.07, 0, 0.96)
|
|
1799
|
+
}
|
|
1800
|
+
case 'sand': {
|
|
1801
|
+
const dune = Math.sin(unitX * 8.5 + Math.sin(unitY * 5.2) * 1.7) * 0.5 + 0.5
|
|
1802
|
+
const ripple = Math.abs(Math.sin(unitX * 39 + unitY * 8 + Math.sin(unitY * 14) * 1.2))
|
|
1803
|
+
const grit = pseudoNoise(x * 7 + 3, y * 11 + 5)
|
|
1804
|
+
const pebble = grit > 0.965 ? 0.48 : 0
|
|
1805
|
+
return THREE.MathUtils.clamp(0.06 + dune * 0.2 + ripple * 0.075 + grit * 0.09 + pebble, 0, 1)
|
|
1806
|
+
}
|
|
1807
|
+
case 'rock': {
|
|
1808
|
+
const ridgeA = Math.abs(Math.sin(unitX * 13 + unitY * 7 + Math.sin(unitY * 11) * 2.1))
|
|
1809
|
+
const ridgeB = Math.abs(Math.sin(unitX * 29 - unitY * 17 + Math.sin(unitX * 9) * 1.3))
|
|
1810
|
+
const strata = Math.abs(Math.sin(unitY * 22 + Math.sin(unitX * 5) * 2.4))
|
|
1811
|
+
const fissure = ridgeB > 0.965 ? 0.54 : 0
|
|
1812
|
+
return THREE.MathUtils.clamp(0.08 + ridgeA * 0.24 + strata * 0.16 + hash * 0.12 + fissure, 0, 1)
|
|
1813
|
+
}
|
|
1814
|
+
case 'gunmetal-silver': {
|
|
1815
|
+
const brushed = Math.abs(Math.sin(unitX * 170 + Math.sin(unitY * 12) * 0.9))
|
|
1816
|
+
const diagonalScratch = Math.abs(((unitX * 1.8 + unitY * 0.38 + 0.17) % 1) - 0.5)
|
|
1817
|
+
const fineScratch = diagonalScratch < 0.009 ? 0.28 : 0
|
|
1818
|
+
const weatherFleck = pseudoNoise(x * 13 + 17, y * 19 + 29) > 0.982 ? 0.34 : 0
|
|
1819
|
+
const softPatina = Math.max(0, Math.sin(unitX * 9.3 + unitY * 7.1) - 0.84) * 0.22
|
|
1820
|
+
return THREE.MathUtils.clamp(
|
|
1821
|
+
0.035 + brushed * 0.085 + hash * 0.055 + fineScratch + weatherFleck + softPatina,
|
|
1822
|
+
0,
|
|
1823
|
+
0.52,
|
|
1824
|
+
)
|
|
1825
|
+
}
|
|
1826
|
+
case 'weathered-sheet-metal':
|
|
1827
|
+
return 0
|
|
1828
|
+
case 'painted-wood': {
|
|
1829
|
+
const plankSeam = y % 21 < 1 ? 0.72 : 0
|
|
1830
|
+
const broadStroke = Math.abs(Math.sin(unitX * 18 + Math.sin(unitY * 8) * 2.4))
|
|
1831
|
+
const fineStroke = Math.abs(Math.sin(unitX * 49 + unitY * 5.5))
|
|
1832
|
+
const knot = Math.hypot((unitX - 0.68) * 1.9, unitY - 0.38)
|
|
1833
|
+
const knotRing = Math.exp(-knot * 11) * Math.abs(Math.sin(knot * 66))
|
|
1834
|
+
return THREE.MathUtils.clamp(
|
|
1835
|
+
0.08 + broadStroke * 0.2 + fineStroke * 0.065 + knotRing * 0.42 + plankSeam + hash * 0.1,
|
|
1836
|
+
0,
|
|
1837
|
+
1,
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
case 'painted-stone': {
|
|
1841
|
+
const cellX = Math.floor(x / 12)
|
|
1842
|
+
const cellY = Math.floor(y / 10)
|
|
1843
|
+
const mortar = x % 12 < 1 || (y % 10 < 1 && (cellX + cellY) % 2 === 0) ? 0.72 : 0
|
|
1844
|
+
const wash = Math.abs(Math.sin(unitX * 7 + unitY * 9)) * 0.12
|
|
1845
|
+
const moss = pseudoNoise(cellX * 5, cellY * 7) > 0.72 ? 0.24 : 0
|
|
1846
|
+
return THREE.MathUtils.clamp(0.08 + mortar + wash + moss + hash * 0.08, 0, 1)
|
|
1847
|
+
}
|
|
1848
|
+
case 'watercolor': {
|
|
1849
|
+
const cloudA = Math.sin(unitX * 7.2 + Math.sin(unitY * 4.1) * 1.6)
|
|
1850
|
+
const cloudB = Math.sin(unitY * 9.4 + Math.sin(unitX * 5.7) * 1.4)
|
|
1851
|
+
return THREE.MathUtils.clamp(0.22 + (cloudA + cloudB + 2) * 0.105 + hash * 0.06, 0, 1)
|
|
1852
|
+
}
|
|
1853
|
+
case 'panels': {
|
|
1854
|
+
const seam = x % 16 < 1 || y % 16 < 1 ? 0.78 : 0
|
|
1855
|
+
const inset = x % 16 === 3 && y % 16 === 3 ? 0.5 : 0
|
|
1856
|
+
return THREE.MathUtils.clamp(0.06 + hash * 0.04 + seam + inset, 0, 1)
|
|
1857
|
+
}
|
|
1858
|
+
case 'grid': {
|
|
1859
|
+
const line = x % 16 < 1 || y % 16 < 1 ? 0.9 : 0
|
|
1860
|
+
return THREE.MathUtils.clamp(0.05 + line + hash * 0.03, 0, 1)
|
|
1861
|
+
}
|
|
1862
|
+
case 'neon-net':
|
|
1863
|
+
return x % 16 < 2 || y % 16 < 2 ? 1 : 0
|
|
1864
|
+
case 'holographic-hover-grid':
|
|
1865
|
+
return 0
|
|
1866
|
+
case 'waves': {
|
|
1867
|
+
const wave = Math.abs(Math.sin(unitX * Math.PI * 5 + Math.sin(unitY * Math.PI * 3)))
|
|
1868
|
+
return 0.08 + wave * 0.3
|
|
1869
|
+
}
|
|
1870
|
+
case 'bark': {
|
|
1871
|
+
const ridge = Math.abs(Math.sin(unitX * 24 + Math.sin(unitY * 10) * 1.7))
|
|
1872
|
+
return THREE.MathUtils.clamp(0.12 + ridge * 0.3 + hash * 0.14, 0, 1)
|
|
1873
|
+
}
|
|
1874
|
+
case 'jungle-vines': {
|
|
1875
|
+
const diagonalA = Math.abs(((x + y * 0.62) % 22) - 11)
|
|
1876
|
+
const diagonalB = Math.abs(((x - y * 0.54 + 96) % 29) - 14.5)
|
|
1877
|
+
const vine = diagonalA < 1.15 || diagonalB < 0.9 ? 0.78 : 0
|
|
1878
|
+
const nodeX = (x + Math.floor(y / 12) * 7) % 24
|
|
1879
|
+
const nodeY = y % 12
|
|
1880
|
+
const leaf = Math.pow((nodeX - 12) / 5.2, 2) + Math.pow((nodeY - 6) / 2.7, 2) < 1 ? 0.52 : 0
|
|
1881
|
+
const vein = leaf > 0 && Math.abs(nodeY - 6) < 0.7 ? 0.18 : 0
|
|
1882
|
+
return THREE.MathUtils.clamp(0.035 + hash * 0.045 + vine + leaf + vein, 0, 1)
|
|
1883
|
+
}
|
|
1884
|
+
case 'leaf-canopy': {
|
|
1885
|
+
const cellSize = 12
|
|
1886
|
+
const cellX = Math.floor(x / cellSize)
|
|
1887
|
+
const cellY = Math.floor(y / cellSize)
|
|
1888
|
+
const localX = (x % cellSize) / cellSize
|
|
1889
|
+
const localY = (y % cellSize) / cellSize
|
|
1890
|
+
const centerX = 0.24 + pseudoNoise(cellX * 19 + 5, cellY * 7 + 3) * 0.52
|
|
1891
|
+
const centerY = 0.2 + pseudoNoise(cellX * 11 + 13, cellY * 17 + 9) * 0.58
|
|
1892
|
+
const angle = pseudoNoise(cellX * 23 + 1, cellY * 29 + 4) * Math.PI
|
|
1893
|
+
const deltaX = localX - centerX
|
|
1894
|
+
const deltaY = localY - centerY
|
|
1895
|
+
const rotatedX = deltaX * Math.cos(angle) - deltaY * Math.sin(angle)
|
|
1896
|
+
const rotatedY = deltaX * Math.sin(angle) + deltaY * Math.cos(angle)
|
|
1897
|
+
const leafShape = Math.abs(rotatedX) * 1.45 + Math.abs(rotatedY) * 3.4
|
|
1898
|
+
const leaf = leafShape < 0.72 ? 0.58 : 0
|
|
1899
|
+
const vein = leaf > 0 && Math.abs(rotatedX) < 0.035 ? 0.24 : 0
|
|
1900
|
+
const overlap = Math.max(0, Math.sin(unitX * 13 + unitY * 17)) * 0.16
|
|
1901
|
+
return THREE.MathUtils.clamp(0.08 + hash * 0.08 + overlap + leaf + vein, 0, 1)
|
|
1902
|
+
}
|
|
1903
|
+
case 'moss-bark': {
|
|
1904
|
+
const ridge = Math.abs(Math.sin(unitX * 25 + Math.sin(unitY * 8.5) * 2.1))
|
|
1905
|
+
const broadWarp = Math.sin(unitX * 6.5 + unitY * 4.2) + Math.sin(unitY * 7.4 - unitX * 3.8)
|
|
1906
|
+
const mossPatch = broadWarp > 0.72 ? 0.58 : broadWarp > 0.32 ? 0.28 : 0
|
|
1907
|
+
return THREE.MathUtils.clamp(0.06 + ridge * 0.22 + mossPatch + hash * 0.1, 0, 1)
|
|
1908
|
+
}
|
|
1909
|
+
case 'ancient-bricks': {
|
|
1910
|
+
const brickHeight = 10
|
|
1911
|
+
const brickWidth = 22
|
|
1912
|
+
const row = Math.floor(y / brickHeight)
|
|
1913
|
+
const shiftedX = (x + (row % 2) * (brickWidth / 2)) % brickWidth
|
|
1914
|
+
const mortar = shiftedX < 1.4 || y % brickHeight < 1.4 ? 0.9 : 0
|
|
1915
|
+
const chippedEdge =
|
|
1916
|
+
(shiftedX < 3 || shiftedX > brickWidth - 3 || y % brickHeight < 3) && pseudoNoise(x * 3 + row, y * 5) > 0.72
|
|
1917
|
+
? 0.28
|
|
1918
|
+
: 0
|
|
1919
|
+
const sunWash = Math.abs(Math.sin(unitX * 8.5 + unitY * 5.2)) * 0.11
|
|
1920
|
+
return THREE.MathUtils.clamp(0.06 + mortar + chippedEdge + sunWash + hash * 0.07, 0, 1)
|
|
1921
|
+
}
|
|
1922
|
+
case 'stick-fence': {
|
|
1923
|
+
const stickWidth = 7
|
|
1924
|
+
const stickOffset = Math.floor(x / stickWidth)
|
|
1925
|
+
const localX = x % stickWidth
|
|
1926
|
+
const seam = localX === 0 || localX === stickWidth - 1 ? 0.78 : 0
|
|
1927
|
+
const irregularEdge = (y + stickOffset * 3) % size < (stickOffset % 3) + 1 ? 0.26 : 0
|
|
1928
|
+
const ropeBand = (y >= 17 && y <= 20) || (y >= 43 && y <= 46) ? 0.34 : 0
|
|
1929
|
+
const grain = Math.abs(Math.sin(unitY * 42 + stickOffset * 1.7)) * 0.12
|
|
1930
|
+
return THREE.MathUtils.clamp(0.08 + seam + irregularEdge + ropeBand + grain + hash * 0.08, 0, 1)
|
|
1931
|
+
}
|
|
1932
|
+
case 'debug':
|
|
1933
|
+
return (x + y) % 8 < 4 ? 0.1 : 0.85
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function pseudoNoise(x: number, y: number): number {
|
|
1938
|
+
const value = Math.sin(x * 12.9898 + y * 78.233) * 43_758.5453
|
|
1939
|
+
return value - Math.floor(value)
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function disposeMaterial(material: THREE.Material): void {
|
|
1943
|
+
const textures = new Set<THREE.Texture>()
|
|
1944
|
+
collectTextures(material, textures)
|
|
1945
|
+
material.dispose()
|
|
1946
|
+
textures.forEach(disposeTexture)
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function disposeTexture(texture: THREE.Texture | null | undefined): void {
|
|
1950
|
+
if (!texture) return
|
|
1951
|
+
if (releaseThemeMaterialTexture(texture)) texture.dispose()
|
|
1952
|
+
}
|