spoint 0.1.663 → 0.1.665
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/client/EntityLoader.js +2 -261
- package/client/EntityLoaderMeshBuild.js +281 -0
- package/package.json +1 -1
package/client/EntityLoader.js
CHANGED
|
@@ -5,270 +5,11 @@ import { InstancedMesh2 } from '@three.ez/instanced-mesh'
|
|
|
5
5
|
import { fetchCached } from './ModelCache.js'
|
|
6
6
|
import { STRINGS } from './core/strings.js'
|
|
7
7
|
import { createStaticInstanceStore } from './core/StaticInstanceStore.js'
|
|
8
|
-
import { buildFluidSurfaceMesh } from './core/FluidSurface.js'
|
|
9
8
|
import { RenderControls } from './core/RenderControls.js'
|
|
10
|
-
|
|
11
|
-
const PLACEHOLDER_DIMS = { door: [1.5, 2.5, 0.1], platform: [4, 0.5, 4], trigger: [2, 3, 2], hazard: [2, 2, 2], lootBox: [1, 1.5, 1], pillar: [1, 4, 1] }
|
|
12
|
-
const MESH_BUILDERS = {
|
|
13
|
-
box: (c) => new THREE.BoxGeometry(c.sx || 1, c.sy || 1, c.sz || 1),
|
|
14
|
-
cylinder: (c) => new THREE.CylinderGeometry(c.r || 0.4, c.r || 0.4, c.h || 0.1, c.seg || 16),
|
|
15
|
-
sphere: (c) => new THREE.SphereGeometry(c.r || 0.5, c.seg || 16, c.seg || 16),
|
|
16
|
-
// Must match AppPhysics.addColliderFromConfig's capsule defaults (r 0.3, h 1.8).
|
|
17
|
-
capsule: (c) => new THREE.CapsuleGeometry(c.r || 0.3, c.h || 1.8, c.cap || 4, c.seg || 16)
|
|
18
|
-
}
|
|
19
|
-
const LOD_CONFIGS = { vrm: { far: 40, skipBeyond: 80 }, box: { far: 45, skipBeyond: 90 }, sphere: { far: 50, skipBeyond: 100 }, cylinder: { far: 50, skipBeyond: 100 }, capsule: { far: 50, skipBeyond: 100 }, default: { far: 60, skipBeyond: 120 } }
|
|
20
|
-
const MAX_CONCURRENT_LOADS_INITIAL = 4, MAX_CONCURRENT_LOADS_RUNTIME = 6
|
|
9
|
+
import { SKIP_MATS_SET, PLACEHOLDER_DIMS, MESH_BUILDERS, LOD_CONFIGS, MAX_CONCURRENT_LOADS_INITIAL, MAX_CONCURRENT_LOADS_RUNTIME, _forceDoubleSide, _buildSoftbodyGeometry, _rewriteSoftbodyGeometry, _makeLabelSprite, _fluidCapacityFor, _buildFluidMesh, _rewriteFluidMesh, _buildFluidSurfaceMesh, _rewriteFluidSurfaceMesh } from './EntityLoaderMeshBuild.js'
|
|
21
10
|
const _urlLoads = new Map()
|
|
22
|
-
function _forceDoubleSide(obj) {
|
|
23
|
-
if (!obj) return
|
|
24
|
-
obj.traverse(c => {
|
|
25
|
-
if (!c.isMesh) return
|
|
26
|
-
const mats = Array.isArray(c.material) ? c.material : [c.material]
|
|
27
|
-
for (const m of mats) { if (m && m.side !== THREE.DoubleSide) { m.side = THREE.DoubleSide; m.needsUpdate = true } }
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
// Soft-body cloth render path (softbody-cloth-client-render-buffergeometry-vertex-path): builds the
|
|
31
|
-
// static, once-per-entity parts of a cols*rows particle-grid mesh -- a plain quad-per-cell triangulation
|
|
32
|
-
// ((cols-1)*(rows-1)*6 indices) and UVs -- shared by every particle-grid entity of the same cols/rows
|
|
33
|
-
// (the index/UV buffers depend only on grid topology, never on the live particle positions), so a fresh
|
|
34
|
-
// BufferGeometry per entity still reuses a cached index/UV pair keyed by "cols,rows" instead of
|
|
35
|
-
// recomputing it on every softbody-bearing entity spawn.
|
|
36
|
-
const _softbodyIndexCache = new Map() // "cols,rows" -> { index: Uint32Array, uv: Float32Array }
|
|
37
|
-
function _softbodyGridTopology(cols, rows) {
|
|
38
|
-
const key = `${cols},${rows}`
|
|
39
|
-
let t = _softbodyIndexCache.get(key)
|
|
40
|
-
if (t) return t
|
|
41
|
-
const uv = new Float32Array(cols * rows * 2)
|
|
42
|
-
for (let row = 0; row < rows; row++) {
|
|
43
|
-
for (let col = 0; col < cols; col++) {
|
|
44
|
-
const i = row * cols + col
|
|
45
|
-
uv[i * 2] = cols > 1 ? col / (cols - 1) : 0
|
|
46
|
-
uv[i * 2 + 1] = rows > 1 ? 1 - row / (rows - 1) : 0
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
const quadCells = Math.max(0, cols - 1) * Math.max(0, rows - 1)
|
|
50
|
-
const index = new Uint32Array(quadCells * 6)
|
|
51
|
-
let w = 0
|
|
52
|
-
for (let row = 0; row < rows - 1; row++) {
|
|
53
|
-
for (let col = 0; col < cols - 1; col++) {
|
|
54
|
-
const a = row * cols + col, b = a + 1, c = a + cols, d = c + 1
|
|
55
|
-
index[w++] = a; index[w++] = c; index[w++] = b
|
|
56
|
-
index[w++] = b; index[w++] = c; index[w++] = d
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
t = { index, uv }
|
|
60
|
-
_softbodyIndexCache.set(key, t)
|
|
61
|
-
return t
|
|
62
|
-
}
|
|
63
|
-
// Builds a fresh BufferGeometry for a softbody-cloth entity's particle grid, positions initialized from
|
|
64
|
-
// custom.softbody.positions (world-space, row-major x,y,z) minus originPos (the entity's own raw
|
|
65
|
-
// authoritative position -- the mesh is parented under a group already translated there, matching every
|
|
66
|
-
// other buildEntityMesh-built primitive's local-space convention). Normals computed once here; every
|
|
67
|
-
// subsequent per-snapshot rewrite (see _rewriteSoftbodyGeometry below) recomputes them too, since a
|
|
68
|
-
// genuinely deforming cloth needs correct per-frame shading, not stale spawn-time normals.
|
|
69
|
-
function _buildSoftbodyGeometry(sb, originPos) {
|
|
70
|
-
const { cols, rows, positions } = sb
|
|
71
|
-
const { index, uv } = _softbodyGridTopology(cols, rows)
|
|
72
|
-
const count = cols * rows
|
|
73
|
-
const pos = new Float32Array(count * 3)
|
|
74
|
-
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
75
|
-
for (let i = 0; i < count; i++) {
|
|
76
|
-
const i3 = i * 3
|
|
77
|
-
pos[i3] = (positions[i3] ?? 0) - ox
|
|
78
|
-
pos[i3 + 1] = (positions[i3 + 1] ?? 0) - oy
|
|
79
|
-
pos[i3 + 2] = (positions[i3 + 2] ?? 0) - oz
|
|
80
|
-
}
|
|
81
|
-
const geo = new THREE.BufferGeometry()
|
|
82
|
-
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
83
|
-
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
84
|
-
geo.setIndex(new THREE.BufferAttribute(index, 1))
|
|
85
|
-
geo.computeVertexNormals()
|
|
86
|
-
geo.computeBoundingSphere()
|
|
87
|
-
return geo
|
|
88
|
-
}
|
|
89
|
-
// Per-snapshot vertex-position REWRITE (not a full geometry rebuild): called from repaintEntity whenever
|
|
90
|
-
// custom.softbody arrives with a topology matching the mesh already built (cols/rows unchanged -- a
|
|
91
|
-
// mid-life cols/rows change is out of scope for this slice, matches softbody.js's own "grid topology is
|
|
92
|
-
// fixed for the entity's life" design; setPin only toggles FIXED/DYNAMIC on an existing point, it never
|
|
93
|
-
// resizes the grid). Returns false (caller should rebuild instead) if topology doesn't match.
|
|
94
|
-
function _rewriteSoftbodyGeometry(mesh, sb, originPos) {
|
|
95
|
-
const geo = mesh.geometry, attr = geo?.attributes?.position
|
|
96
|
-
const count = sb.cols * sb.rows
|
|
97
|
-
if (!attr || attr.count !== count) return false
|
|
98
|
-
const arr = attr.array, positions = sb.positions
|
|
99
|
-
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
100
|
-
for (let i = 0; i < count; i++) {
|
|
101
|
-
const i3 = i * 3
|
|
102
|
-
arr[i3] = (positions[i3] ?? 0) - ox
|
|
103
|
-
arr[i3 + 1] = (positions[i3 + 1] ?? 0) - oy
|
|
104
|
-
arr[i3 + 2] = (positions[i3 + 2] ?? 0) - oz
|
|
105
|
-
}
|
|
106
|
-
attr.needsUpdate = true
|
|
107
|
-
geo.computeVertexNormals()
|
|
108
|
-
geo.computeBoundingSphere()
|
|
109
|
-
return true
|
|
110
|
-
}
|
|
111
|
-
// Freddie-bridge viz entity label: canvas-texture sprite floating above the entity, matching the
|
|
112
|
-
// pattern WaypointPath.js's _makeOrderLabelSprite already uses (sprite, not CSS2D, so it works in
|
|
113
|
-
// the 3D scene without a separate CSS2DRenderer pass). Cached per entityId in _labelSprites so
|
|
114
|
-
// repaintEntity can update/remove the label without a full scene traverse.
|
|
115
11
|
const _labelSprites = new Map() // entityId -> THREE.Sprite
|
|
116
|
-
|
|
117
|
-
const canvas = document.createElement('canvas')
|
|
118
|
-
canvas.width = 256; canvas.height = 64
|
|
119
|
-
const ctx = canvas.getContext('2d')
|
|
120
|
-
ctx.clearRect(0, 0, 256, 64)
|
|
121
|
-
// Semi-transparent dark background pill
|
|
122
|
-
const tw = ctx.measureText(text || '').width
|
|
123
|
-
const pw = Math.min(240, Math.max(40, tw + 24))
|
|
124
|
-
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
|
125
|
-
_roundRect(ctx, (256 - pw) / 2, 4, pw, 56, 12)
|
|
126
|
-
ctx.fill()
|
|
127
|
-
ctx.fillStyle = '#ffffff'
|
|
128
|
-
ctx.font = 'bold 24px sans-serif'
|
|
129
|
-
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
|
|
130
|
-
ctx.fillText(String(text || ''), 128, 34)
|
|
131
|
-
const tex = new THREE.CanvasTexture(canvas)
|
|
132
|
-
tex.minFilter = THREE.LinearFilter
|
|
133
|
-
const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true, opacity: 0.9 })
|
|
134
|
-
const sprite = new THREE.Sprite(mat)
|
|
135
|
-
sprite.scale.set(2, 0.5, 1)
|
|
136
|
-
sprite.renderOrder = 999
|
|
137
|
-
return sprite
|
|
138
|
-
}
|
|
139
|
-
function _roundRect(ctx, x, y, w, h, r) {
|
|
140
|
-
ctx.beginPath()
|
|
141
|
-
ctx.moveTo(x + r, y)
|
|
142
|
-
ctx.lineTo(x + w - r, y)
|
|
143
|
-
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
|
144
|
-
ctx.lineTo(x + w, y + h - r)
|
|
145
|
-
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
|
146
|
-
ctx.lineTo(x + r, y + h)
|
|
147
|
-
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
|
148
|
-
ctx.lineTo(x, y + r)
|
|
149
|
-
ctx.quadraticCurveTo(x, y, x + r, y)
|
|
150
|
-
ctx.closePath()
|
|
151
|
-
}
|
|
152
|
-
// SPH fluid particle-cloud render path (sph-fluid-client-render-particle-mesh): custom.fluid present
|
|
153
|
-
// means this entity is a live particle cloud published by apps/_lib/fluid.js's publish() -- see that
|
|
154
|
-
// module's doc comment for the wire shape: {particleCount, positions:[x,y,z,...]} world-space, row-major,
|
|
155
|
-
// flat number array (NOT a typed array on the wire -- msgpackr-serialized plain array). This is the
|
|
156
|
-
// simplest/cheapest of the two candidate approaches this row's own detail names (InstancedMesh2 of small
|
|
157
|
-
// spheres vs a metaball/marching-squares surface reconstruction) -- droplets/foam look, not a smooth
|
|
158
|
-
// fluid surface, but real and cheap, matching the same @three.ez/instanced-mesh primitive already proven
|
|
159
|
-
// at scale for grass/veg/rain (see AGENTS.md grass-commitchunk-batched-addinstances + Weather.js's own
|
|
160
|
-
// im.instances[i].position.set(...); inst.updateMatrix() per-frame-mutation pattern this mirrors exactly).
|
|
161
|
-
// Capacity is fixed at build time to the entity's spawn-time custom.fluid.particleCount rounded up to the
|
|
162
|
-
// nearest FLUID_CAPACITY_STEP (so a slowly-growing emitter doesn't force a capacity rebuild on every tick)
|
|
163
|
-
// clamped to FLUID_MAX_CAPACITY -- a hard ceiling independent of any one instance's own maxParticles spec
|
|
164
|
-
// field, since a scene could host multiple fluid sources and this is a per-entity GPU buffer allocation.
|
|
165
|
-
const FLUID_CAPACITY_STEP = 128, FLUID_MAX_CAPACITY = 4096
|
|
166
|
-
function _fluidCapacityFor(particleCount) {
|
|
167
|
-
const n = Math.max(FLUID_CAPACITY_STEP, Math.ceil((particleCount || 1) / FLUID_CAPACITY_STEP) * FLUID_CAPACITY_STEP)
|
|
168
|
-
return Math.min(n, FLUID_MAX_CAPACITY)
|
|
169
|
-
}
|
|
170
|
-
// Builds the InstancedMesh2 droplet cloud for a fluid entity. originPos is the entity's own raw spawn
|
|
171
|
-
// position (mesh.userData convention shared with every other buildEntityMesh primitive: positions written
|
|
172
|
-
// into the mesh are LOCAL to the entity's group, which is itself translated to originPos) -- but fluid.js
|
|
173
|
-
// publishes WORLD-space positions (its own doc comment: "so positions()/the published wire buffer are
|
|
174
|
-
// real world-space [x,y,z] triples", it maps its 2D solver plane onto world X/Z at a fixed worldY), so
|
|
175
|
-
// every published position needs originPos subtracted, exactly like _buildSoftbodyGeometry does for
|
|
176
|
-
// custom.softbody.positions.
|
|
177
|
-
function _buildFluidMesh(fluid, originPos, renderer) {
|
|
178
|
-
const capacity = _fluidCapacityFor(fluid.particleCount)
|
|
179
|
-
const radius = fluid.particleRadius || 0.08
|
|
180
|
-
const geo = new THREE.SphereGeometry(radius, 8, 6)
|
|
181
|
-
const mat = new THREE.MeshStandardMaterial({ color: fluid.color ?? 0x3a8bd8, roughness: 0.15, metalness: 0.05, transparent: true, opacity: 0.85 })
|
|
182
|
-
const im = new InstancedMesh2(geo, mat, { capacity, renderer, createEntities: true })
|
|
183
|
-
im.castShadow = false; im.receiveShadow = false
|
|
184
|
-
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
185
|
-
const positions = fluid.positions || []
|
|
186
|
-
const count = Math.min(fluid.particleCount || 0, capacity)
|
|
187
|
-
if (count > 0) {
|
|
188
|
-
im.addInstances(count, (entity, id) => {
|
|
189
|
-
const i3 = id * 3
|
|
190
|
-
entity.position.set((positions[i3] ?? 0) - ox, (positions[i3 + 1] ?? 0) - oy, (positions[i3 + 2] ?? 0) - oz)
|
|
191
|
-
entity.updateMatrix()
|
|
192
|
-
})
|
|
193
|
-
}
|
|
194
|
-
im.userData.isFluid = true
|
|
195
|
-
im.userData._fluidCapacity = capacity
|
|
196
|
-
im.userData._fluidCount = count
|
|
197
|
-
im.userData._fluidOrigin = [ox, oy, oz]
|
|
198
|
-
return im
|
|
199
|
-
}
|
|
200
|
-
// Per-snapshot position REWRITE for an already-built fluid InstancedMesh2. Grows the live instance count
|
|
201
|
-
// (im.addInstances) when the published particleCount increases (an emitter still spawning), up to the
|
|
202
|
-
// mesh's fixed capacity -- a growth past capacity is silently clamped (matches fluid.js's own maxParticles
|
|
203
|
-
// cap discipline; a scene with many fluid sources needs a hard per-entity ceiling regardless). Returns
|
|
204
|
-
// false if the mesh's capacity has been exceeded and a full rebuild is warranted (mirrors
|
|
205
|
-
// _rewriteSoftbodyGeometry's own false-means-rebuild contract), though in practice FLUID_MAX_CAPACITY is
|
|
206
|
-
// only reached by a misconfigured spec since fluid-source's own editorProps cap maxParticles at 4096.
|
|
207
|
-
function _rewriteFluidMesh(im, fluid, originPos) {
|
|
208
|
-
if (!im || !im.userData.isFluid) return false
|
|
209
|
-
const capacity = im.userData._fluidCapacity
|
|
210
|
-
const positions = fluid.positions || []
|
|
211
|
-
const wantCount = Math.min(fluid.particleCount || 0, capacity)
|
|
212
|
-
const haveCount = im.userData._fluidCount || 0
|
|
213
|
-
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
214
|
-
im.userData._fluidOrigin = [ox, oy, oz]
|
|
215
|
-
if (wantCount > haveCount) {
|
|
216
|
-
im.addInstances(wantCount - haveCount, (entity, id) => {
|
|
217
|
-
const i3 = id * 3
|
|
218
|
-
entity.position.set((positions[i3] ?? 0) - ox, (positions[i3 + 1] ?? 0) - oy, (positions[i3 + 2] ?? 0) - oz)
|
|
219
|
-
entity.updateMatrix()
|
|
220
|
-
})
|
|
221
|
-
im.userData._fluidCount = wantCount
|
|
222
|
-
}
|
|
223
|
-
const n = Math.min(wantCount, im.userData._fluidCount || 0)
|
|
224
|
-
for (let id = 0; id < n; id++) {
|
|
225
|
-
const i3 = id * 3
|
|
226
|
-
const inst = im.instances[id]; if (!inst) continue
|
|
227
|
-
inst.position.set((positions[i3] ?? 0) - ox, (positions[i3 + 1] ?? 0) - oy, (positions[i3 + 2] ?? 0) - oz)
|
|
228
|
-
inst.updateMatrix()
|
|
229
|
-
}
|
|
230
|
-
return true
|
|
231
|
-
}
|
|
232
|
-
// SPH fluid metaball/marching-squares SURFACE render path (sph-fluid-client-render-metaball-surface-
|
|
233
|
-
// evaluation, follow-on to the InstancedMesh2 droplet cloud above): opt-in alternative render mode for
|
|
234
|
-
// the SAME custom.fluid wire data, selected per-entity at first-build time via
|
|
235
|
-
// RenderControls.get('fluidRenderMode') === 'surface' (default stays 'droplets', the shipped baseline --
|
|
236
|
-
// this path never runs unless explicitly enabled). Builds a real THREE.Mesh whose geometry is a
|
|
237
|
-
// FluidSurface.buildFluidSurfaceMesh contour, full-REBUILT every snapshot (not vertex-rewritten in place
|
|
238
|
-
// like softbody/droplets -- marching squares can change vertex/index COUNT every step as particles
|
|
239
|
-
// cross the isosurface threshold differently, so there is no fixed-topology buffer to rewrite into,
|
|
240
|
-
// unlike the softbody grid's fixed cols*rows or the droplet cloud's fixed capacity; this is the real,
|
|
241
|
-
// honest cost difference the row's own perf-A/B measures, not an implementation shortcut).
|
|
242
|
-
function _buildFluidSurfaceMesh(fluid, originPos, cellSize, halfThickness) {
|
|
243
|
-
const geo = buildFluidSurfaceMesh(THREE, fluid.positions || [], fluid.particleCount || 0, originPos, fluid.smoothingRadius || 0.5, cellSize, halfThickness)
|
|
244
|
-
const mat = new THREE.MeshStandardMaterial({ color: fluid.color ?? 0x3a8bd8, roughness: 0.1, metalness: 0.05, transparent: true, opacity: 0.85, side: THREE.DoubleSide })
|
|
245
|
-
const mesh = new THREE.Mesh(geo || new THREE.BufferGeometry(), mat)
|
|
246
|
-
mesh.castShadow = false; mesh.receiveShadow = false
|
|
247
|
-
mesh.userData.isFluidSurface = true
|
|
248
|
-
return mesh
|
|
249
|
-
}
|
|
250
|
-
// Per-snapshot REBUILD (see comment above for why this is a rebuild not a rewrite) + live perf stats,
|
|
251
|
-
// mirrored onto RenderControls' fluidSurfaceStats (window.__fluidSurfaceStats) so the row's own required
|
|
252
|
-
// live-measured-cost evaluation is a standing, inspectable number, not a one-off console.log.
|
|
253
|
-
let _fluidSurfaceSamples = 0, _fluidSurfaceTotalMs = 0
|
|
254
|
-
function _rewriteFluidSurfaceMesh(mesh, fluid, originPos, cellSize, halfThickness) {
|
|
255
|
-
if (!mesh || !mesh.userData.isFluidSurface) return false
|
|
256
|
-
const t0 = (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
|
257
|
-
const geo = buildFluidSurfaceMesh(THREE, fluid.positions || [], fluid.particleCount || 0, originPos, fluid.smoothingRadius || 0.5, cellSize, halfThickness)
|
|
258
|
-
const t1 = (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
|
259
|
-
const ms = t1 - t0
|
|
260
|
-
if (geo) {
|
|
261
|
-
const old = mesh.geometry
|
|
262
|
-
mesh.geometry = geo
|
|
263
|
-
if (old) old.dispose()
|
|
264
|
-
}
|
|
265
|
-
_fluidSurfaceSamples++
|
|
266
|
-
_fluidSurfaceTotalMs += ms
|
|
267
|
-
if (typeof window !== 'undefined') {
|
|
268
|
-
window.__fluidSurfaceStats = { lastMs: ms, avgMs: _fluidSurfaceTotalMs / _fluidSurfaceSamples, samples: _fluidSurfaceSamples, particleCount: fluid.particleCount || 0 }
|
|
269
|
-
}
|
|
270
|
-
return true
|
|
271
|
-
}
|
|
12
|
+
|
|
272
13
|
export function createEntityLoader(scene, gltfLoader, cam, loadingMgr, patchGLB, sceneGraph, modelPool = null, opts = {}) {
|
|
273
14
|
let _onMeshReady = null, _onTrimeshReady = null
|
|
274
15
|
// Opt-in (default OFF, per the PRD row's own "staged rollout behind a feature flag" scope note):
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Mesh-build/rewrite helpers for EntityLoader.js's createEntityLoader: softbody-cloth particle-grid
|
|
2
|
+
// geometry, the freddie-bridge label sprite, and SPH-fluid droplet/surface render paths. Split out as
|
|
3
|
+
// EntityLoader.js's largest self-contained block -- each function here only touches its own
|
|
4
|
+
// module-scoped caches (_softbodyIndexCache, _fluidSurfaceSamples/_fluidSurfaceTotalMs) or explicit
|
|
5
|
+
// params, never createEntityLoader's own closure state. Entity-id-keyed caches (_labelSprites,
|
|
6
|
+
// _urlLoads) stay in EntityLoader.js since createEntityLoader itself reads/writes them directly.
|
|
7
|
+
|
|
8
|
+
import * as THREE from 'three'
|
|
9
|
+
import { InstancedMesh2 } from '@three.ez/instanced-mesh'
|
|
10
|
+
import { buildFluidSurfaceMesh } from './core/FluidSurface.js'
|
|
11
|
+
|
|
12
|
+
const SKIP_MATS_SET = new Set(['aaatrigger', '{invisible', 'playerclip', 'clip', 'nodraw', 'trigger', 'sky', 'toolsclip', 'toolsplayerclip', 'toolsnodraw', 'toolsskybox', 'toolstrigger'])
|
|
13
|
+
const PLACEHOLDER_DIMS = { door: [1.5, 2.5, 0.1], platform: [4, 0.5, 4], trigger: [2, 3, 2], hazard: [2, 2, 2], lootBox: [1, 1.5, 1], pillar: [1, 4, 1] }
|
|
14
|
+
const MESH_BUILDERS = {
|
|
15
|
+
box: (c) => new THREE.BoxGeometry(c.sx || 1, c.sy || 1, c.sz || 1),
|
|
16
|
+
cylinder: (c) => new THREE.CylinderGeometry(c.r || 0.4, c.r || 0.4, c.h || 0.1, c.seg || 16),
|
|
17
|
+
sphere: (c) => new THREE.SphereGeometry(c.r || 0.5, c.seg || 16, c.seg || 16),
|
|
18
|
+
// Must match AppPhysics.addColliderFromConfig's capsule defaults (r 0.3, h 1.8).
|
|
19
|
+
capsule: (c) => new THREE.CapsuleGeometry(c.r || 0.3, c.h || 1.8, c.cap || 4, c.seg || 16)
|
|
20
|
+
}
|
|
21
|
+
const LOD_CONFIGS = { vrm: { far: 40, skipBeyond: 80 }, box: { far: 45, skipBeyond: 90 }, sphere: { far: 50, skipBeyond: 100 }, cylinder: { far: 50, skipBeyond: 100 }, capsule: { far: 50, skipBeyond: 100 }, default: { far: 60, skipBeyond: 120 } }
|
|
22
|
+
const MAX_CONCURRENT_LOADS_INITIAL = 4, MAX_CONCURRENT_LOADS_RUNTIME = 6
|
|
23
|
+
const _urlLoads = new Map()
|
|
24
|
+
function _forceDoubleSide(obj) {
|
|
25
|
+
if (!obj) return
|
|
26
|
+
obj.traverse(c => {
|
|
27
|
+
if (!c.isMesh) return
|
|
28
|
+
const mats = Array.isArray(c.material) ? c.material : [c.material]
|
|
29
|
+
for (const m of mats) { if (m && m.side !== THREE.DoubleSide) { m.side = THREE.DoubleSide; m.needsUpdate = true } }
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
// Soft-body cloth render path (softbody-cloth-client-render-buffergeometry-vertex-path): builds the
|
|
33
|
+
// static, once-per-entity parts of a cols*rows particle-grid mesh -- a plain quad-per-cell triangulation
|
|
34
|
+
// ((cols-1)*(rows-1)*6 indices) and UVs -- shared by every particle-grid entity of the same cols/rows
|
|
35
|
+
// (the index/UV buffers depend only on grid topology, never on the live particle positions), so a fresh
|
|
36
|
+
// BufferGeometry per entity still reuses a cached index/UV pair keyed by "cols,rows" instead of
|
|
37
|
+
// recomputing it on every softbody-bearing entity spawn.
|
|
38
|
+
const _softbodyIndexCache = new Map() // "cols,rows" -> { index: Uint32Array, uv: Float32Array }
|
|
39
|
+
function _softbodyGridTopology(cols, rows) {
|
|
40
|
+
const key = `${cols},${rows}`
|
|
41
|
+
let t = _softbodyIndexCache.get(key)
|
|
42
|
+
if (t) return t
|
|
43
|
+
const uv = new Float32Array(cols * rows * 2)
|
|
44
|
+
for (let row = 0; row < rows; row++) {
|
|
45
|
+
for (let col = 0; col < cols; col++) {
|
|
46
|
+
const i = row * cols + col
|
|
47
|
+
uv[i * 2] = cols > 1 ? col / (cols - 1) : 0
|
|
48
|
+
uv[i * 2 + 1] = rows > 1 ? 1 - row / (rows - 1) : 0
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const quadCells = Math.max(0, cols - 1) * Math.max(0, rows - 1)
|
|
52
|
+
const index = new Uint32Array(quadCells * 6)
|
|
53
|
+
let w = 0
|
|
54
|
+
for (let row = 0; row < rows - 1; row++) {
|
|
55
|
+
for (let col = 0; col < cols - 1; col++) {
|
|
56
|
+
const a = row * cols + col, b = a + 1, c = a + cols, d = c + 1
|
|
57
|
+
index[w++] = a; index[w++] = c; index[w++] = b
|
|
58
|
+
index[w++] = b; index[w++] = c; index[w++] = d
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
t = { index, uv }
|
|
62
|
+
_softbodyIndexCache.set(key, t)
|
|
63
|
+
return t
|
|
64
|
+
}
|
|
65
|
+
// Builds a fresh BufferGeometry for a softbody-cloth entity's particle grid, positions initialized from
|
|
66
|
+
// custom.softbody.positions (world-space, row-major x,y,z) minus originPos (the entity's own raw
|
|
67
|
+
// authoritative position -- the mesh is parented under a group already translated there, matching every
|
|
68
|
+
// other buildEntityMesh-built primitive's local-space convention). Normals computed once here; every
|
|
69
|
+
// subsequent per-snapshot rewrite (see _rewriteSoftbodyGeometry below) recomputes them too, since a
|
|
70
|
+
// genuinely deforming cloth needs correct per-frame shading, not stale spawn-time normals.
|
|
71
|
+
function _buildSoftbodyGeometry(sb, originPos) {
|
|
72
|
+
const { cols, rows, positions } = sb
|
|
73
|
+
const { index, uv } = _softbodyGridTopology(cols, rows)
|
|
74
|
+
const count = cols * rows
|
|
75
|
+
const pos = new Float32Array(count * 3)
|
|
76
|
+
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
77
|
+
for (let i = 0; i < count; i++) {
|
|
78
|
+
const i3 = i * 3
|
|
79
|
+
pos[i3] = (positions[i3] ?? 0) - ox
|
|
80
|
+
pos[i3 + 1] = (positions[i3 + 1] ?? 0) - oy
|
|
81
|
+
pos[i3 + 2] = (positions[i3 + 2] ?? 0) - oz
|
|
82
|
+
}
|
|
83
|
+
const geo = new THREE.BufferGeometry()
|
|
84
|
+
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
85
|
+
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
86
|
+
geo.setIndex(new THREE.BufferAttribute(index, 1))
|
|
87
|
+
geo.computeVertexNormals()
|
|
88
|
+
geo.computeBoundingSphere()
|
|
89
|
+
return geo
|
|
90
|
+
}
|
|
91
|
+
// Per-snapshot vertex-position REWRITE (not a full geometry rebuild): called from repaintEntity whenever
|
|
92
|
+
// custom.softbody arrives with a topology matching the mesh already built (cols/rows unchanged -- a
|
|
93
|
+
// mid-life cols/rows change is out of scope for this slice, matches softbody.js's own "grid topology is
|
|
94
|
+
// fixed for the entity's life" design; setPin only toggles FIXED/DYNAMIC on an existing point, it never
|
|
95
|
+
// resizes the grid). Returns false (caller should rebuild instead) if topology doesn't match.
|
|
96
|
+
function _rewriteSoftbodyGeometry(mesh, sb, originPos) {
|
|
97
|
+
const geo = mesh.geometry, attr = geo?.attributes?.position
|
|
98
|
+
const count = sb.cols * sb.rows
|
|
99
|
+
if (!attr || attr.count !== count) return false
|
|
100
|
+
const arr = attr.array, positions = sb.positions
|
|
101
|
+
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
102
|
+
for (let i = 0; i < count; i++) {
|
|
103
|
+
const i3 = i * 3
|
|
104
|
+
arr[i3] = (positions[i3] ?? 0) - ox
|
|
105
|
+
arr[i3 + 1] = (positions[i3 + 1] ?? 0) - oy
|
|
106
|
+
arr[i3 + 2] = (positions[i3 + 2] ?? 0) - oz
|
|
107
|
+
}
|
|
108
|
+
attr.needsUpdate = true
|
|
109
|
+
geo.computeVertexNormals()
|
|
110
|
+
geo.computeBoundingSphere()
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
// Freddie-bridge viz entity label: canvas-texture sprite floating above the entity, matching the
|
|
114
|
+
// pattern WaypointPath.js's _makeOrderLabelSprite already uses (sprite, not CSS2D, so it works in
|
|
115
|
+
// the 3D scene without a separate CSS2DRenderer pass). Caller caches the returned sprite per entityId
|
|
116
|
+
// (EntityLoader.js's own _labelSprites map) so repaintEntity can update/remove it without a full
|
|
117
|
+
// scene traverse -- this function itself is stateless.
|
|
118
|
+
function _makeLabelSprite(text) {
|
|
119
|
+
const canvas = document.createElement('canvas')
|
|
120
|
+
canvas.width = 256; canvas.height = 64
|
|
121
|
+
const ctx = canvas.getContext('2d')
|
|
122
|
+
ctx.clearRect(0, 0, 256, 64)
|
|
123
|
+
// Semi-transparent dark background pill
|
|
124
|
+
const tw = ctx.measureText(text || '').width
|
|
125
|
+
const pw = Math.min(240, Math.max(40, tw + 24))
|
|
126
|
+
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
|
127
|
+
_roundRect(ctx, (256 - pw) / 2, 4, pw, 56, 12)
|
|
128
|
+
ctx.fill()
|
|
129
|
+
ctx.fillStyle = '#ffffff'
|
|
130
|
+
ctx.font = 'bold 24px sans-serif'
|
|
131
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
|
|
132
|
+
ctx.fillText(String(text || ''), 128, 34)
|
|
133
|
+
const tex = new THREE.CanvasTexture(canvas)
|
|
134
|
+
tex.minFilter = THREE.LinearFilter
|
|
135
|
+
const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true, opacity: 0.9 })
|
|
136
|
+
const sprite = new THREE.Sprite(mat)
|
|
137
|
+
sprite.scale.set(2, 0.5, 1)
|
|
138
|
+
sprite.renderOrder = 999
|
|
139
|
+
return sprite
|
|
140
|
+
}
|
|
141
|
+
function _roundRect(ctx, x, y, w, h, r) {
|
|
142
|
+
ctx.beginPath()
|
|
143
|
+
ctx.moveTo(x + r, y)
|
|
144
|
+
ctx.lineTo(x + w - r, y)
|
|
145
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
|
146
|
+
ctx.lineTo(x + w, y + h - r)
|
|
147
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
|
148
|
+
ctx.lineTo(x + r, y + h)
|
|
149
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
|
150
|
+
ctx.lineTo(x, y + r)
|
|
151
|
+
ctx.quadraticCurveTo(x, y, x + r, y)
|
|
152
|
+
ctx.closePath()
|
|
153
|
+
}
|
|
154
|
+
// SPH fluid particle-cloud render path (sph-fluid-client-render-particle-mesh): custom.fluid present
|
|
155
|
+
// means this entity is a live particle cloud published by apps/_lib/fluid.js's publish() -- see that
|
|
156
|
+
// module's doc comment for the wire shape: {particleCount, positions:[x,y,z,...]} world-space, row-major,
|
|
157
|
+
// flat number array (NOT a typed array on the wire -- msgpackr-serialized plain array). This is the
|
|
158
|
+
// simplest/cheapest of the two candidate approaches this row's own detail names (InstancedMesh2 of small
|
|
159
|
+
// spheres vs a metaball/marching-squares surface reconstruction) -- droplets/foam look, not a smooth
|
|
160
|
+
// fluid surface, but real and cheap, matching the same @three.ez/instanced-mesh primitive already proven
|
|
161
|
+
// at scale for grass/veg/rain (see AGENTS.md grass-commitchunk-batched-addinstances + Weather.js's own
|
|
162
|
+
// im.instances[i].position.set(...); inst.updateMatrix() per-frame-mutation pattern this mirrors exactly).
|
|
163
|
+
// Capacity is fixed at build time to the entity's spawn-time custom.fluid.particleCount rounded up to the
|
|
164
|
+
// nearest FLUID_CAPACITY_STEP (so a slowly-growing emitter doesn't force a capacity rebuild on every tick)
|
|
165
|
+
// clamped to FLUID_MAX_CAPACITY -- a hard ceiling independent of any one instance's own maxParticles spec
|
|
166
|
+
// field, since a scene could host multiple fluid sources and this is a per-entity GPU buffer allocation.
|
|
167
|
+
const FLUID_CAPACITY_STEP = 128, FLUID_MAX_CAPACITY = 4096
|
|
168
|
+
function _fluidCapacityFor(particleCount) {
|
|
169
|
+
const n = Math.max(FLUID_CAPACITY_STEP, Math.ceil((particleCount || 1) / FLUID_CAPACITY_STEP) * FLUID_CAPACITY_STEP)
|
|
170
|
+
return Math.min(n, FLUID_MAX_CAPACITY)
|
|
171
|
+
}
|
|
172
|
+
// Builds the InstancedMesh2 droplet cloud for a fluid entity. originPos is the entity's own raw spawn
|
|
173
|
+
// position (mesh.userData convention shared with every other buildEntityMesh primitive: positions written
|
|
174
|
+
// into the mesh are LOCAL to the entity's group, which is itself translated to originPos) -- but fluid.js
|
|
175
|
+
// publishes WORLD-space positions (its own doc comment: "so positions()/the published wire buffer are
|
|
176
|
+
// real world-space [x,y,z] triples", it maps its 2D solver plane onto world X/Z at a fixed worldY), so
|
|
177
|
+
// every published position needs originPos subtracted, exactly like _buildSoftbodyGeometry does for
|
|
178
|
+
// custom.softbody.positions.
|
|
179
|
+
function _buildFluidMesh(fluid, originPos, renderer) {
|
|
180
|
+
const capacity = _fluidCapacityFor(fluid.particleCount)
|
|
181
|
+
const radius = fluid.particleRadius || 0.08
|
|
182
|
+
const geo = new THREE.SphereGeometry(radius, 8, 6)
|
|
183
|
+
const mat = new THREE.MeshStandardMaterial({ color: fluid.color ?? 0x3a8bd8, roughness: 0.15, metalness: 0.05, transparent: true, opacity: 0.85 })
|
|
184
|
+
const im = new InstancedMesh2(geo, mat, { capacity, renderer, createEntities: true })
|
|
185
|
+
im.castShadow = false; im.receiveShadow = false
|
|
186
|
+
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
187
|
+
const positions = fluid.positions || []
|
|
188
|
+
const count = Math.min(fluid.particleCount || 0, capacity)
|
|
189
|
+
if (count > 0) {
|
|
190
|
+
im.addInstances(count, (entity, id) => {
|
|
191
|
+
const i3 = id * 3
|
|
192
|
+
entity.position.set((positions[i3] ?? 0) - ox, (positions[i3 + 1] ?? 0) - oy, (positions[i3 + 2] ?? 0) - oz)
|
|
193
|
+
entity.updateMatrix()
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
im.userData.isFluid = true
|
|
197
|
+
im.userData._fluidCapacity = capacity
|
|
198
|
+
im.userData._fluidCount = count
|
|
199
|
+
im.userData._fluidOrigin = [ox, oy, oz]
|
|
200
|
+
return im
|
|
201
|
+
}
|
|
202
|
+
// Per-snapshot position REWRITE for an already-built fluid InstancedMesh2. Grows the live instance count
|
|
203
|
+
// (im.addInstances) when the published particleCount increases (an emitter still spawning), up to the
|
|
204
|
+
// mesh's fixed capacity -- a growth past capacity is silently clamped (matches fluid.js's own maxParticles
|
|
205
|
+
// cap discipline; a scene with many fluid sources needs a hard per-entity ceiling regardless). Returns
|
|
206
|
+
// false if the mesh's capacity has been exceeded and a full rebuild is warranted (mirrors
|
|
207
|
+
// _rewriteSoftbodyGeometry's own false-means-rebuild contract), though in practice FLUID_MAX_CAPACITY is
|
|
208
|
+
// only reached by a misconfigured spec since fluid-source's own editorProps cap maxParticles at 4096.
|
|
209
|
+
function _rewriteFluidMesh(im, fluid, originPos) {
|
|
210
|
+
if (!im || !im.userData.isFluid) return false
|
|
211
|
+
const capacity = im.userData._fluidCapacity
|
|
212
|
+
const positions = fluid.positions || []
|
|
213
|
+
const wantCount = Math.min(fluid.particleCount || 0, capacity)
|
|
214
|
+
const haveCount = im.userData._fluidCount || 0
|
|
215
|
+
const ox = originPos?.[0] || 0, oy = originPos?.[1] || 0, oz = originPos?.[2] || 0
|
|
216
|
+
im.userData._fluidOrigin = [ox, oy, oz]
|
|
217
|
+
if (wantCount > haveCount) {
|
|
218
|
+
im.addInstances(wantCount - haveCount, (entity, id) => {
|
|
219
|
+
const i3 = id * 3
|
|
220
|
+
entity.position.set((positions[i3] ?? 0) - ox, (positions[i3 + 1] ?? 0) - oy, (positions[i3 + 2] ?? 0) - oz)
|
|
221
|
+
entity.updateMatrix()
|
|
222
|
+
})
|
|
223
|
+
im.userData._fluidCount = wantCount
|
|
224
|
+
}
|
|
225
|
+
const n = Math.min(wantCount, im.userData._fluidCount || 0)
|
|
226
|
+
for (let id = 0; id < n; id++) {
|
|
227
|
+
const i3 = id * 3
|
|
228
|
+
const inst = im.instances[id]; if (!inst) continue
|
|
229
|
+
inst.position.set((positions[i3] ?? 0) - ox, (positions[i3 + 1] ?? 0) - oy, (positions[i3 + 2] ?? 0) - oz)
|
|
230
|
+
inst.updateMatrix()
|
|
231
|
+
}
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
234
|
+
// SPH fluid metaball/marching-squares SURFACE render path (sph-fluid-client-render-metaball-surface-
|
|
235
|
+
// evaluation, follow-on to the InstancedMesh2 droplet cloud above): opt-in alternative render mode for
|
|
236
|
+
// the SAME custom.fluid wire data, selected per-entity at first-build time via
|
|
237
|
+
// RenderControls.get('fluidRenderMode') === 'surface' (default stays 'droplets', the shipped baseline --
|
|
238
|
+
// this path never runs unless explicitly enabled). Builds a real THREE.Mesh whose geometry is a
|
|
239
|
+
// FluidSurface.buildFluidSurfaceMesh contour, full-REBUILT every snapshot (not vertex-rewritten in place
|
|
240
|
+
// like softbody/droplets -- marching squares can change vertex/index COUNT every step as particles
|
|
241
|
+
// cross the isosurface threshold differently, so there is no fixed-topology buffer to rewrite into,
|
|
242
|
+
// unlike the softbody grid's fixed cols*rows or the droplet cloud's fixed capacity; this is the real,
|
|
243
|
+
// honest cost difference the row's own perf-A/B measures, not an implementation shortcut).
|
|
244
|
+
function _buildFluidSurfaceMesh(fluid, originPos, cellSize, halfThickness) {
|
|
245
|
+
const geo = buildFluidSurfaceMesh(THREE, fluid.positions || [], fluid.particleCount || 0, originPos, fluid.smoothingRadius || 0.5, cellSize, halfThickness)
|
|
246
|
+
const mat = new THREE.MeshStandardMaterial({ color: fluid.color ?? 0x3a8bd8, roughness: 0.1, metalness: 0.05, transparent: true, opacity: 0.85, side: THREE.DoubleSide })
|
|
247
|
+
const mesh = new THREE.Mesh(geo || new THREE.BufferGeometry(), mat)
|
|
248
|
+
mesh.castShadow = false; mesh.receiveShadow = false
|
|
249
|
+
mesh.userData.isFluidSurface = true
|
|
250
|
+
return mesh
|
|
251
|
+
}
|
|
252
|
+
// Per-snapshot REBUILD (see comment above for why this is a rebuild not a rewrite) + live perf stats,
|
|
253
|
+
// mirrored onto RenderControls' fluidSurfaceStats (window.__fluidSurfaceStats) so the row's own required
|
|
254
|
+
// live-measured-cost evaluation is a standing, inspectable number, not a one-off console.log.
|
|
255
|
+
let _fluidSurfaceSamples = 0, _fluidSurfaceTotalMs = 0
|
|
256
|
+
function _rewriteFluidSurfaceMesh(mesh, fluid, originPos, cellSize, halfThickness) {
|
|
257
|
+
if (!mesh || !mesh.userData.isFluidSurface) return false
|
|
258
|
+
const t0 = (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
|
259
|
+
const geo = buildFluidSurfaceMesh(THREE, fluid.positions || [], fluid.particleCount || 0, originPos, fluid.smoothingRadius || 0.5, cellSize, halfThickness)
|
|
260
|
+
const t1 = (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
|
261
|
+
const ms = t1 - t0
|
|
262
|
+
if (geo) {
|
|
263
|
+
const old = mesh.geometry
|
|
264
|
+
mesh.geometry = geo
|
|
265
|
+
if (old) old.dispose()
|
|
266
|
+
}
|
|
267
|
+
_fluidSurfaceSamples++
|
|
268
|
+
_fluidSurfaceTotalMs += ms
|
|
269
|
+
if (typeof window !== 'undefined') {
|
|
270
|
+
window.__fluidSurfaceStats = { lastMs: ms, avgMs: _fluidSurfaceTotalMs / _fluidSurfaceSamples, samples: _fluidSurfaceSamples, particleCount: fluid.particleCount || 0 }
|
|
271
|
+
}
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export {
|
|
276
|
+
SKIP_MATS_SET, PLACEHOLDER_DIMS, MESH_BUILDERS, LOD_CONFIGS,
|
|
277
|
+
MAX_CONCURRENT_LOADS_INITIAL, MAX_CONCURRENT_LOADS_RUNTIME,
|
|
278
|
+
_forceDoubleSide, _buildSoftbodyGeometry, _rewriteSoftbodyGeometry,
|
|
279
|
+
_makeLabelSprite, _fluidCapacityFor, _buildFluidMesh, _rewriteFluidMesh,
|
|
280
|
+
_buildFluidSurfaceMesh, _rewriteFluidSurfaceMesh
|
|
281
|
+
}
|