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.
@@ -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
- const SKIP_MATS_SET = new Set(['aaatrigger', '{invisible', 'playerclip', 'clip', 'nodraw', 'trigger', 'sky', 'toolsclip', 'toolsplayerclip', 'toolsnodraw', 'toolsskybox', 'toolstrigger'])
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
- function _makeLabelSprite(text) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.663",
3
+ "version": "0.1.665",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [