@downdraft/engine 0.2.0 → 0.3.1
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/app/src/renderer/bootstrap.ts +0 -6
- package/app/src/renderer/feature-log.ts +1 -9
- package/app/src/renderer/mcp-harness.ts +8 -11
- package/app/src/renderer/standard-automation-tools.ts +8 -145
- package/core/src/index.ts +2 -2
- package/core/src/module/host.ts +3 -6
- package/core/src/module/index.ts +1 -1
- package/core/src/module/module.ts +4 -47
- package/core/src/physics/interface.ts +5 -0
- package/core/src/platform/render-surface.ts +13 -0
- package/core/src/platform/rpc.ts +4 -1
- package/core/src/render/canvas-resize-watcher.ts +1 -10
- package/core/src/render/game-renderer.ts +164 -79
- package/core/src/render/index.ts +0 -2
- package/core/src/render/input-manager.ts +7 -7
- package/core/src/render/renderer-module-host.ts +2 -4
- package/core/src/sab/grid-buffer.ts +55 -2
- package/core/src/sab/record-buffer.ts +48 -5
- package/core/src/sab/sim-channel.ts +181 -17
- package/core/src/sab/slot-buffer.ts +55 -1
- package/core/src/sab/types.ts +11 -0
- package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
- package/core/src/ui-font-scale.ts +5 -38
- package/core/src/util/index.ts +1 -0
- package/core/src/util/session-timer.ts +23 -0
- package/core/src/worker/base-worker-host.ts +2 -1
- package/core/src/worker/buffer-sync.ts +20 -13
- package/core/src/worker/rpc.ts +119 -5
- package/core/src/worker/sim-worker-base.ts +5 -2
- package/core/src/worker/task-pool.ts +4 -0
- package/dist/.dd-manifest.json +893 -908
- package/dist/app/src/renderer/bootstrap.js +0 -4
- package/dist/app/src/renderer/feature-log.js +1 -10
- package/dist/app/src/renderer/mcp-harness.js +8 -11
- package/dist/app/src/renderer/standard-automation-tools.js +6 -138
- package/dist/core/src/index.js +2 -2
- package/dist/core/src/module/host.js +3 -6
- package/dist/core/src/platform/rpc.js +2 -1
- package/dist/core/src/render/canvas-resize-watcher.js +1 -10
- package/dist/core/src/render/game-renderer.js +166 -74
- package/dist/core/src/render/index.js +0 -1
- package/dist/core/src/render/input-manager.js +7 -7
- package/dist/core/src/render/renderer-module-host.js +2 -4
- package/dist/core/src/sab/grid-buffer.js +53 -1
- package/dist/core/src/sab/record-buffer.js +49 -5
- package/dist/core/src/sab/sim-channel.js +173 -17
- package/dist/core/src/sab/slot-buffer.js +54 -1
- package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
- package/dist/core/src/ui-font-scale.js +5 -38
- package/dist/core/src/util/index.js +1 -0
- package/dist/core/src/util/session-timer.js +22 -0
- package/dist/core/src/worker/base-worker-host.js +2 -1
- package/dist/core/src/worker/buffer-sync.js +16 -9
- package/dist/core/src/worker/rpc.js +105 -8
- package/dist/core/src/worker/sim-worker-base.js +5 -2
- package/dist/core/src/worker/task-pool.js +5 -0
- package/dist/libraries/blitz-ui/src/native-module.js +8 -1
- package/dist/libraries/devtools/src/backend.js +35 -9
- package/dist/libraries/devtools/src/collectors.js +73 -22
- package/dist/libraries/devtools/src/native-providers.js +0 -2
- package/dist/libraries/entities/src/model-renderer.js +87 -27
- package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
- package/dist/libraries/networking/src/index.js +1 -0
- package/dist/libraries/networking/src/ws-server.js +387 -0
- package/dist/libraries/physics-rapier/src/api.js +15 -0
- package/dist/libraries/physics-rapier/src/backend.js +15 -0
- package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
- package/dist/libraries/postfx/src/post-process-stack.js +1 -3
- package/dist/libraries/sand/src/materials.js +2 -32
- package/dist/libraries/sand/src/sand-world.js +0 -3
- package/dist/modules/devtools/src/api.js +8 -23
- package/dist/modules/devtools/src/blitz/fonts.js +75 -0
- package/dist/modules/devtools/src/blitz/host.js +124 -30
- package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
- package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
- package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
- package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
- package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
- package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
- package/dist/modules/devtools/src/blitz/theme.js +26 -4
- package/dist/modules/devtools/src/data-bridge.js +0 -11
- package/dist/modules/devtools/src/index.js +0 -6
- package/dist/modules/devtools/src/init.js +8 -42
- package/dist/modules/devtools/src/scene-inspector.js +1 -4
- package/dist/modules/devtools/src/worker-sync.js +3 -11
- package/dist/modules/html-ui/src/doc-backend.js +52 -3
- package/dist/modules/html-ui/src/host.js +6 -3
- package/dist/test/src/vision-test.js +1 -3
- package/libraries/blitz-ui/native/Cargo.toml +6 -0
- package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
- package/libraries/blitz-ui/src/native-module.ts +8 -1
- package/libraries/devtools/src/backend.ts +38 -12
- package/libraries/devtools/src/collectors.ts +75 -22
- package/libraries/devtools/src/native-providers.ts +0 -2
- package/libraries/entities/src/model-renderer.ts +88 -27
- package/libraries/entities/src/shaders/model.wgsl +13 -4
- package/libraries/networking/src/index.ts +4 -0
- package/libraries/networking/src/ws-server.ts +397 -0
- package/libraries/physics-rapier/src/api.ts +15 -0
- package/libraries/physics-rapier/src/backend.ts +15 -0
- package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
- package/libraries/postfx/src/post-process-stack.ts +1 -2
- package/libraries/sand/src/materials.ts +2 -32
- package/libraries/sand/src/sand-world.ts +0 -4
- package/modules/devtools/README.md +1 -3
- package/modules/devtools/src/api.ts +10 -34
- package/modules/devtools/src/blitz/fonts.ts +78 -0
- package/modules/devtools/src/blitz/host.ts +107 -31
- package/modules/devtools/src/blitz/panels/console.ts +20 -8
- package/modules/devtools/src/blitz/panels/elements.ts +212 -31
- package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
- package/modules/devtools/src/blitz/panels/performance.ts +24 -12
- package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
- package/modules/devtools/src/blitz/panels/types.ts +2 -1
- package/modules/devtools/src/blitz/theme.ts +26 -4
- package/modules/devtools/src/data-bridge.ts +0 -16
- package/modules/devtools/src/index.ts +1 -11
- package/modules/devtools/src/init.ts +8 -51
- package/modules/devtools/src/scene-inspector.ts +2 -6
- package/modules/devtools/src/types.ts +5 -90
- package/modules/devtools/src/worker-sync.ts +3 -13
- package/modules/html-ui/src/doc-backend.ts +67 -4
- package/modules/html-ui/src/host.ts +11 -3
- package/modules/html-ui/src/protocol.ts +1 -1
- package/package.json +4 -5
- package/test/src/vision-test.ts +2 -4
- package/core/src/render/sab-canvas-overlay.ts +0 -122
- package/dist/core/src/render/sab-canvas-overlay.js +0 -83
- package/dist/modules/devtools/src/debug-overlay.js +0 -181
- package/dist/modules/devtools/src/doctor-panel.js +0 -163
- package/dist/modules/devtools/src/label-overlay.js +0 -129
- package/dist/modules/devtools/src/material-stats-panel.js +0 -171
- package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
- package/modules/devtools/src/debug-overlay.ts +0 -238
- package/modules/devtools/src/doctor-panel.ts +0 -182
- package/modules/devtools/src/label-overlay.ts +0 -154
- package/modules/devtools/src/material-stats-panel.ts +0 -188
- package/modules/devtools/src/sim-stats-panel.ts +0 -272
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// — no per-draw bind-group creation or texture bind-group churn.
|
|
10
10
|
// ============================================================================
|
|
11
11
|
import { calculateViewProj, createLogger, createValidatedShaderModule, DEPTH_FORMAT, MSAA_SAMPLE_COUNT } from "@downdraft/engine";
|
|
12
|
-
const MODEL_WGSL = "struct Uniforms {\n viewProj: mat4x4<f32>,\n cameraPos: vec3<f32>,\n time: f32,\n modelPos: vec3<f32>,\n modelScale: vec3<f32>,\n modelRot: vec4<f32>,\n materialIndex: u32,\n lightDirX: f32,\n lightDirY: f32,\n lightDirZ: f32,\n lightAmbient: f32,\n lightIntensity: f32,\n // Per-draw highlight mode: 0 = normal shading, 1 = ghost hologram (cyan,\n // used for the physgun's no-collision grab), 2 = hover outline (drawn by a\n // separate inverted-hull pipeline — see vs_outline / fs_outline).\n // Values >= 3 encode per-prop material overrides (spawn settings):\n // combined = u32(highlight) - 3\n // shaderMode = combined & 3 (0=Standard 1=Toon 2=Hologram 3=Outline)\n // texMode = combined >> 2 (0=Default 1=Wireframe 2=Checker)\n highlight: f32,\n // Inverted-hull outline parameters (used by vs_outline / fs_outline only).\n outlineWidth: f32,\n outlineColor: vec3<f32>,\n};\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n\n// Skin matrices for skinned meshes (@group(1)). A dynamic storage buffer of\n// mat4x4<f32>, one per bone. Updated per-frame by the renderer via\n// updateSkinMatrices(). Only bound when drawing skinned meshes.\n@group(1) @binding(0) var<storage, read> skinMatrices: array<mat4x4<f32>>;\n\n// Frame-global lighting (@group(2)): sun color, hemisphere ambient, point lights.\n// Games that want colored lighting provide a compatible uniform buffer; the\n// default bind group supplies a neutral white sun + no point lights so existing\n// consumers see no visual change.\nstruct FrameLighting {\n sunDir: vec3<f32>,\n ambientIntensity: f32,\n sunColor: vec3<f32>,\n pointLightCount: u32,\n skyAmbient: vec3<f32>,\n _pad0: u32,\n groundAmbient: vec3<f32>,\n _pad1: u32,\n pointLights: array<vec4<f32>, 16>,\n};\n@group(2) @binding(0) var<uniform> frameLighting: FrameLighting;\n\n// Bindless material binding model (@group(3)):\n// binding 0: material SSBO (read-only storage)\n// bindings 1..8: texture_2d_array pages (rgba8unorm color textures)\n// binding 9: shared sampler (repeat)\n// binding 10: shared sampler (clamp)\nstruct BindlessMaterial {\n baseColor: vec4<f32>,\n roughness: f32,\n metallic: f32,\n emissiveIntensity: f32,\n hasTexTransform: f32,\n albedoTex: u32,\n normalTex: u32,\n metallicRoughnessTex: u32,\n aoEmissiveTex: u32,\n texOffset: vec2<f32>,\n texScale: vec2<f32>,\n texRotation: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\n@group(3) @binding(0) var<storage, read> bindlessMaterials: array<BindlessMaterial>;\n@group(3) @binding(1) var albedoArray0: texture_2d_array<f32>;\n@group(3) @binding(2) var albedoArray1: texture_2d_array<f32>;\n@group(3) @binding(3) var albedoArray2: texture_2d_array<f32>;\n@group(3) @binding(4) var albedoArray3: texture_2d_array<f32>;\n@group(3) @binding(5) var albedoArray4: texture_2d_array<f32>;\n@group(3) @binding(6) var albedoArray5: texture_2d_array<f32>;\n@group(3) @binding(7) var albedoArray6: texture_2d_array<f32>;\n@group(3) @binding(8) var albedoArray7: texture_2d_array<f32>;\n@group(3) @binding(9) var bindlessSamplerRepeat: sampler;\n@group(3) @binding(10) var bindlessSamplerClamp: sampler;\n\nfn unpackArrayIndex(handle: u32) -> u32 { return (handle >> 16u) & 0xFFFFu; }\nfn unpackLayerIndex(handle: u32) -> u32 { return handle & 0xFFFFu; }\n\nfn sampleBindlessArray(arr: u32, uv: vec2<f32>, layer: u32) -> vec4<f32> {\n // textureSample (auto-LOD) so mipmapped registrations actually filter;\n // single-mip textures behave identically to textureSampleLevel(..., 0.0).\n switch (arr) {\n case 0u: { return textureSample(albedoArray0, bindlessSamplerRepeat, uv, layer); }\n case 1u: { return textureSample(albedoArray1, bindlessSamplerRepeat, uv, layer); }\n case 2u: { return textureSample(albedoArray2, bindlessSamplerRepeat, uv, layer); }\n case 3u: { return textureSample(albedoArray3, bindlessSamplerRepeat, uv, layer); }\n case 4u: { return textureSample(albedoArray4, bindlessSamplerRepeat, uv, layer); }\n case 5u: { return textureSample(albedoArray5, bindlessSamplerRepeat, uv, layer); }\n case 6u: { return textureSample(albedoArray6, bindlessSamplerRepeat, uv, layer); }\n case 7u: { return textureSample(albedoArray7, bindlessSamplerRepeat, uv, layer); }\n default: { return vec4<f32>(1.0, 1.0, 1.0, 1.0); }\n }\n}\n\nstruct VertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Skinned vertex input — extends VertexInput with joints (4 bone indices,\n// packed as vec4<u32>) and weights (4 normalized bone weights). Lives in a\n// second vertex buffer slot (buffer index 1).\nstruct SkinnedVertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n @location(4) joints: vec4<u32>,\n @location(5) weights: vec4<f32>,\n};\n\nstruct VertexOutput {\n @builtin(position) clipPos: vec4<f32>,\n @location(0) worldPos: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Build a tangent basis from the geometric normal. Uses a reference vector\n// instead of screen-space derivatives (dpdx/dpdy) which can produce NaN on\n// some implementations / degenerate UVs. The tangent direction is arbitrary\n// (determined by the reference vector) but is sufficient for normal mapping\n// on meshes without explicit tangent attributes.\nfn buildTangentBasis(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>) -> mat3x3<f32> {\n var refVec = vec3<f32>(0.0, 1.0, 0.0);\n if (abs(N.y) >= 0.99) {\n refVec = vec3<f32>(1.0, 0.0, 0.0);\n }\n let T = normalize(cross(refVec, N));\n let B = cross(N, T);\n return mat3x3<f32>(T, B, N);\n}\n\n// Sample the normal map and perturb the geometric normal. Returns the\n// perturbed world-space normal. If the material has no normal map (normalTex\n// points at the default white texture), the sampled tangent-space normal is\n// (0.5, 0.5, 1.0) → unpacked to (0, 0, 1) → no perturbation.\nfn perturbNormal(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>, normalTexHandle: u32) -> vec3<f32> {\n let arr = unpackArrayIndex(normalTexHandle);\n let layer = unpackLayerIndex(normalTexHandle);\n let sampled = sampleBindlessArray(arr, uv, layer);\n let tangentNormal = sampled.xyz * 2.0 - 1.0;\n let basis = buildTangentBasis(N, worldPos, uv);\n return normalize(basis * tangentNormal);\n}\n\nfn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {\n return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);\n}\n\n// Accumulate the 4-bone skinning matrix: sum(weights[i] * skinMatrices[joint[i]]).\n// If all weights are zero (vertex has no bone assignment), return identity so\n// the vertex renders at its baked position instead of collapsing to the origin.\nfn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {\n let m = skinMatrices[j.x] * w.x\n + skinMatrices[j.y] * w.y\n + skinMatrices[j.z] * w.z\n + skinMatrices[j.w] * w.w;\n let wsum = w.x + w.y + w.z + w.w;\n if (wsum < 0.001) {\n return mat4x4<f32>(\n vec4(1.0, 0.0, 0.0, 0.0),\n vec4(0.0, 1.0, 0.0, 0.0),\n vec4(0.0, 0.0, 1.0, 0.0),\n vec4(0.0, 0.0, 0.0, 1.0),\n );\n }\n return m;\n}\n\n@vertex\nfn vs_main(input: VertexInput) -> VertexOutput {\n var output: VertexOutput;\n let scaled = input.position * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, input.normal));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n// Skinned vertex entry point. Applies linear blend skinning in model space\n// (skinMatrices already encode bone-world * inverseBind), then the same\n// model transform (scale/rot/pos) as vs_main.\n@vertex\nfn vs_skinned(input: SkinnedVertexInput) -> VertexOutput {\n var output: VertexOutput;\n let sm = skinMatrix(input.joints, input.weights);\n let skinnedPos = (sm * vec4<f32>(input.position, 1.0)).xyz;\n let skinnedNormal = (sm * vec4<f32>(input.normal, 0.0)).xyz;\n\n let scaled = skinnedPos * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, normalize(skinnedNormal)));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n@fragment\nfn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {\n let geometricN = normalize(input.normal);\n\n // Bindless material lookup (needed for normal map handle + albedo).\n let m = bindlessMaterials[uniforms.materialIndex];\n\n // Perturb the normal using the material's normal map (if any). Materials\n // without a normal map use the default flat-normal texture (0.5,0.5,1.0)\n // → unpacked to (0,0,1) → no perturbation.\n let N = perturbNormal(geometricN, vec4<f32>(input.worldPos, 1.0), input.uv, m.normalTex);\n\n // Decode per-prop material overrides (highlight >= 3).\n var shaderMode = 0u;\n var texMode = 0u;\n if (uniforms.highlight >= 3.0) {\n let combined = u32(uniforms.highlight) - 3u;\n shaderMode = combined & 3u;\n texMode = combined >> 2u;\n }\n\n // Colored directional sun light — matches the procedural cube/sphere shader.\n let sunDir = normalize(frameLighting.sunDir);\n var ndotl = max(dot(N, sunDir), 0.0);\n // Toon shader override: quantize the sun term into discrete bands.\n if (shaderMode == 1u) {\n ndotl = floor(ndotl * 3.0 + 0.5) / 3.0;\n ndotl = max(ndotl, 0.15);\n }\n let sunDiffuse = ndotl * frameLighting.sunColor;\n\n // Hemisphere ambient: blend sky/ground based on normal.y (same as cube shader).\n let hemiMix = N.y * 0.5 + 0.5;\n let hemisphere = mix(frameLighting.groundAmbient, frameLighting.skyAmbient, hemiMix);\n let ambient = hemisphere * frameLighting.ambientIntensity;\n\n // Bindless albedo sample: index the material SSBO by materialIndex, then\n // sample the texture_2d_array page/layer the material points at.\n let arr = unpackArrayIndex(m.albedoTex);\n let layer = unpackLayerIndex(m.albedoTex);\n let texColor = sampleBindlessArray(arr, input.uv, layer);\n\n // All inputs are sRGB: texture (rgba8unorm), vertex color (white), and\n // baseColor (parser converts FBX linear DiffuseColor to sRGB). The swapchain\n // is non-sRGB (bgra8unorm), so output sRGB directly.\n var baseColor = texColor.rgb * input.color * m.baseColor.rgb;\n\n // ── Texture override modes ──\n // Wireframe: darken albedo, UV-grid lines overlaid after lighting.\n // Checker: two-tone checkerboard over the UVs.\n var texLine = 0.0;\n if (texMode == 1u) {\n let g = fract(input.uv * 6.0);\n let lx = min(g.x, 1.0 - g.x);\n let ly = min(g.y, 1.0 - g.y);\n texLine = 1.0 - smoothstep(0.0, 0.08, min(lx, ly));\n baseColor = baseColor * 0.15;\n } else if (texMode == 2u) {\n let cell = floor(input.uv * 8.0);\n let check = (cell.x + cell.y) - floor((cell.x + cell.y) * 0.5) * 2.0;\n baseColor = mix(baseColor * 0.35, baseColor * 1.4 + vec3<f32>(0.08), check);\n }\n\n // Channel-inspection modes — bypass lighting entirely for material review.\n // 3 = perturbed normals (normal-map contribution visible), 4 = unlit albedo\n // (texture/baseColor only), 5 = geometric normals (no normal map).\n if (texMode == 3u) { return vec4<f32>(N * 0.5 + vec3<f32>(0.5), 1.0); }\n if (texMode == 4u) { return vec4<f32>(baseColor, 1.0); }\n if (texMode == 5u) { return vec4<f32>(geometricN * 0.5 + vec3<f32>(0.5), 1.0); }\n\n var litColor = baseColor * (ambient + sunDiffuse);\n\n // Point lights (up to 8). Each light is 2 vec4s: (pos.xyz, radius) + (color.rgb, intensity).\n let plCount = frameLighting.pointLightCount;\n for (var i = 0u; i < plCount; i++) {\n let pl0 = frameLighting.pointLights[i * 2u];\n let pl1 = frameLighting.pointLights[i * 2u + 1u];\n let plPos = pl0.xyz;\n let plRadius = pl0.w;\n let plColor = pl1.xyz;\n let plIntensity = pl1.w;\n let L = plPos - input.worldPos;\n let dist = length(L);\n if (dist < plRadius) {\n let atten = 1.0 / (1.0 + dist * dist / (plRadius * plRadius));\n let ndotl_pl = max(dot(N, normalize(L)), 0.0);\n litColor += baseColor * plColor * plIntensity * atten * ndotl_pl;\n }\n }\n\n // Wireframe overlay: paint the UV-grid lines in a bright tint.\n if (texLine > 0.0) {\n litColor = mix(litColor, vec3<f32>(0.55, 0.95, 1.0), texLine * 0.9);\n }\n\n // Distance fog — anchored to the camera→model distance so it scales with\n // the subject instead of a fixed 400m absolute (a 200m model viewed from\n // 500m would otherwise fog to the ambient color entirely). Small models\n // near the camera keep the original ~400m reach.\n let modelDist = length(uniforms.cameraPos - uniforms.modelPos);\n let fogStart = max(modelDist * 1.5, 60.0);\n let fogEnd = max(modelDist * 4.0, 400.0);\n let dist = length(uniforms.cameraPos - input.worldPos);\n let fog = clamp(1.0 - (dist - fogStart) / (fogEnd - fogStart), 0.0, 1.0);\n var outColor = mix(frameLighting.skyAmbient, litColor, fog);\n\n // Outline shader override (shaderMode == 3): darken the body and add a\n // bright fresnel rim so the prop reads with a bold cel edge.\n if (shaderMode == 3u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 3.0);\n outColor = outColor * 0.7 + vec3<f32>(1.0, 0.95, 0.6) * smoothstep(0.35, 0.9, fresnel);\n }\n\n // Hologram shader override (shaderMode == 2): fresnel rim + scanline pulse\n // tinted toward the prop's own base color. Opaque output (the pipeline has\n // no blend state) but reads as a hologram.\n if (shaderMode == 2u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let holoBase = baseColor * vec3<f32>(0.3, 0.9, 1.1) + vec3<f32>(0.05, 0.2, 0.25);\n let holoRim = vec3<f32>(0.7, 1.0, 1.0);\n var holoCol = mix(holoBase, holoRim, fresnel) * pulse;\n holoCol = holoCol + holoRim * scan * 0.15 * fresnel;\n return vec4<f32>(holoCol, 1.0);\n }\n\n // Ghost hologram override (highlight == 1): render the prop as a\n // translucent-looking cyan shell with a fresnel rim and a vertical\n // scanline pulse so it's visually distinct from normally-grabbed (solid)\n // props. Opaque output (the pipeline has no blend state) but reads as a\n // hologram. Hover outline (highlight == 2) is handled by a separate\n // inverted-hull draw — see vs_outline / fs_outline below.\n if (uniforms.highlight > 0.5 && uniforms.highlight < 1.5) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let ghostBase = vec3<f32>(0.15, 0.75, 0.95);\n let ghostRim = vec3<f32>(0.7, 1.0, 1.0);\n var ghostCol = mix(ghostBase, ghostRim, fresnel) * pulse;\n ghostCol = ghostCol + ghostRim * scan * 0.15 * fresnel;\n return vec4<f32>(ghostCol, 1.0);\n }\n\n return vec4<f32>(outColor, 1.0);\n}\n\n// ── Mask entry point for post-process outline ──\n// Renders the model as solid white to a mask texture. Uses the same\n// vertex shader as the normal render (vs_main / vs_skinned).\n@fragment\nfn fs_mask() -> @location(0) vec4f {\n return vec4f(1.0, 1.0, 1.0, 1.0);\n}\n";
|
|
12
|
+
const MODEL_WGSL = "struct Uniforms {\n viewProj: mat4x4<f32>,\n cameraPos: vec3<f32>,\n time: f32,\n modelPos: vec3<f32>,\n modelScale: vec3<f32>,\n modelRot: vec4<f32>,\n materialIndex: u32,\n lightDirX: f32,\n lightDirY: f32,\n lightDirZ: f32,\n lightAmbient: f32,\n lightIntensity: f32,\n // Per-draw highlight mode: 0 = normal shading, 1 = ghost hologram (cyan,\n // used for the physgun's no-collision grab), 2 = hover outline (drawn by a\n // separate inverted-hull pipeline — see vs_outline / fs_outline).\n // Values >= 3 encode per-prop material overrides (spawn settings):\n // combined = u32(highlight) - 3\n // shaderMode = combined & 3 (0=Standard 1=Toon 2=Hologram 3=Outline)\n // texMode = combined >> 2 (0=Default 1=Wireframe 2=Checker)\n highlight: f32,\n // Inverted-hull outline parameters (used by vs_outline / fs_outline only).\n outlineWidth: f32,\n outlineColor: vec3<f32>,\n // Base index of this draw's bone region inside the shared skinMatrices\n // storage buffer (group(1)). Written by ModelRenderer from the node's\n // reserved region — lets multiple skinned models animate independently.\n // Only read by vs_skinned.\n boneBase: u32,\n _padSkin0: u32,\n _padSkin1: u32,\n _padSkin2: u32,\n};\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n\n// Skin matrices for skinned meshes (@group(1)). A dynamic storage buffer of\n// mat4x4<f32>, one per bone. Updated per-frame by the renderer via\n// updateSkinMatrices(). Only bound when drawing skinned meshes.\n@group(1) @binding(0) var<storage, read> skinMatrices: array<mat4x4<f32>>;\n\n// Frame-global lighting (@group(2)): sun color, hemisphere ambient, point lights.\n// Games that want colored lighting provide a compatible uniform buffer; the\n// default bind group supplies a neutral white sun + no point lights so existing\n// consumers see no visual change.\nstruct FrameLighting {\n sunDir: vec3<f32>,\n ambientIntensity: f32,\n sunColor: vec3<f32>,\n pointLightCount: u32,\n skyAmbient: vec3<f32>,\n _pad0: u32,\n groundAmbient: vec3<f32>,\n _pad1: u32,\n pointLights: array<vec4<f32>, 16>,\n};\n@group(2) @binding(0) var<uniform> frameLighting: FrameLighting;\n\n// Bindless material binding model (@group(3)):\n// binding 0: material SSBO (read-only storage)\n// bindings 1..8: texture_2d_array pages (rgba8unorm color textures)\n// binding 9: shared sampler (repeat)\n// binding 10: shared sampler (clamp)\nstruct BindlessMaterial {\n baseColor: vec4<f32>,\n roughness: f32,\n metallic: f32,\n emissiveIntensity: f32,\n hasTexTransform: f32,\n albedoTex: u32,\n normalTex: u32,\n metallicRoughnessTex: u32,\n aoEmissiveTex: u32,\n texOffset: vec2<f32>,\n texScale: vec2<f32>,\n texRotation: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\n@group(3) @binding(0) var<storage, read> bindlessMaterials: array<BindlessMaterial>;\n@group(3) @binding(1) var albedoArray0: texture_2d_array<f32>;\n@group(3) @binding(2) var albedoArray1: texture_2d_array<f32>;\n@group(3) @binding(3) var albedoArray2: texture_2d_array<f32>;\n@group(3) @binding(4) var albedoArray3: texture_2d_array<f32>;\n@group(3) @binding(5) var albedoArray4: texture_2d_array<f32>;\n@group(3) @binding(6) var albedoArray5: texture_2d_array<f32>;\n@group(3) @binding(7) var albedoArray6: texture_2d_array<f32>;\n@group(3) @binding(8) var albedoArray7: texture_2d_array<f32>;\n@group(3) @binding(9) var bindlessSamplerRepeat: sampler;\n@group(3) @binding(10) var bindlessSamplerClamp: sampler;\n\nfn unpackArrayIndex(handle: u32) -> u32 { return (handle >> 16u) & 0xFFFFu; }\nfn unpackLayerIndex(handle: u32) -> u32 { return handle & 0xFFFFu; }\n\nfn sampleBindlessArray(arr: u32, uv: vec2<f32>, layer: u32) -> vec4<f32> {\n // textureSample (auto-LOD) so mipmapped registrations actually filter;\n // single-mip textures behave identically to textureSampleLevel(..., 0.0).\n switch (arr) {\n case 0u: { return textureSample(albedoArray0, bindlessSamplerRepeat, uv, layer); }\n case 1u: { return textureSample(albedoArray1, bindlessSamplerRepeat, uv, layer); }\n case 2u: { return textureSample(albedoArray2, bindlessSamplerRepeat, uv, layer); }\n case 3u: { return textureSample(albedoArray3, bindlessSamplerRepeat, uv, layer); }\n case 4u: { return textureSample(albedoArray4, bindlessSamplerRepeat, uv, layer); }\n case 5u: { return textureSample(albedoArray5, bindlessSamplerRepeat, uv, layer); }\n case 6u: { return textureSample(albedoArray6, bindlessSamplerRepeat, uv, layer); }\n case 7u: { return textureSample(albedoArray7, bindlessSamplerRepeat, uv, layer); }\n default: { return vec4<f32>(1.0, 1.0, 1.0, 1.0); }\n }\n}\n\nstruct VertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Skinned vertex input — extends VertexInput with joints (4 bone indices,\n// packed as vec4<u32>) and weights (4 normalized bone weights). Lives in a\n// second vertex buffer slot (buffer index 1).\nstruct SkinnedVertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n @location(4) joints: vec4<u32>,\n @location(5) weights: vec4<f32>,\n};\n\nstruct VertexOutput {\n @builtin(position) clipPos: vec4<f32>,\n @location(0) worldPos: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Build a tangent basis from the geometric normal. Uses a reference vector\n// instead of screen-space derivatives (dpdx/dpdy) which can produce NaN on\n// some implementations / degenerate UVs. The tangent direction is arbitrary\n// (determined by the reference vector) but is sufficient for normal mapping\n// on meshes without explicit tangent attributes.\nfn buildTangentBasis(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>) -> mat3x3<f32> {\n var refVec = vec3<f32>(0.0, 1.0, 0.0);\n if (abs(N.y) >= 0.99) {\n refVec = vec3<f32>(1.0, 0.0, 0.0);\n }\n let T = normalize(cross(refVec, N));\n let B = cross(N, T);\n return mat3x3<f32>(T, B, N);\n}\n\n// Sample the normal map and perturb the geometric normal. Returns the\n// perturbed world-space normal. If the material has no normal map (normalTex\n// points at the default white texture), the sampled tangent-space normal is\n// (0.5, 0.5, 1.0) → unpacked to (0, 0, 1) → no perturbation.\nfn perturbNormal(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>, normalTexHandle: u32) -> vec3<f32> {\n let arr = unpackArrayIndex(normalTexHandle);\n let layer = unpackLayerIndex(normalTexHandle);\n let sampled = sampleBindlessArray(arr, uv, layer);\n let tangentNormal = sampled.xyz * 2.0 - 1.0;\n let basis = buildTangentBasis(N, worldPos, uv);\n return normalize(basis * tangentNormal);\n}\n\nfn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {\n return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);\n}\n\n// Accumulate the 4-bone skinning matrix: sum(weights[i] * skinMatrices[joint[i]]).\n// If all weights are zero (vertex has no bone assignment), return identity so\n// the vertex renders at its baked position instead of collapsing to the origin.\nfn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {\n let base = uniforms.boneBase;\n let m = skinMatrices[base + j.x] * w.x\n + skinMatrices[base + j.y] * w.y\n + skinMatrices[base + j.z] * w.z\n + skinMatrices[base + j.w] * w.w;\n let wsum = w.x + w.y + w.z + w.w;\n if (wsum < 0.001) {\n return mat4x4<f32>(\n vec4(1.0, 0.0, 0.0, 0.0),\n vec4(0.0, 1.0, 0.0, 0.0),\n vec4(0.0, 0.0, 1.0, 0.0),\n vec4(0.0, 0.0, 0.0, 1.0),\n );\n }\n return m;\n}\n\n@vertex\nfn vs_main(input: VertexInput) -> VertexOutput {\n var output: VertexOutput;\n let scaled = input.position * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, input.normal));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n// Skinned vertex entry point. Applies linear blend skinning in model space\n// (skinMatrices already encode bone-world * inverseBind), then the same\n// model transform (scale/rot/pos) as vs_main.\n@vertex\nfn vs_skinned(input: SkinnedVertexInput) -> VertexOutput {\n var output: VertexOutput;\n let sm = skinMatrix(input.joints, input.weights);\n let skinnedPos = (sm * vec4<f32>(input.position, 1.0)).xyz;\n let skinnedNormal = (sm * vec4<f32>(input.normal, 0.0)).xyz;\n\n let scaled = skinnedPos * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, normalize(skinnedNormal)));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n@fragment\nfn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {\n let geometricN = normalize(input.normal);\n\n // Bindless material lookup (needed for normal map handle + albedo).\n let m = bindlessMaterials[uniforms.materialIndex];\n\n // Perturb the normal using the material's normal map (if any). Materials\n // without a normal map use the default flat-normal texture (0.5,0.5,1.0)\n // → unpacked to (0,0,1) → no perturbation.\n let N = perturbNormal(geometricN, vec4<f32>(input.worldPos, 1.0), input.uv, m.normalTex);\n\n // Decode per-prop material overrides (highlight >= 3).\n var shaderMode = 0u;\n var texMode = 0u;\n if (uniforms.highlight >= 3.0) {\n let combined = u32(uniforms.highlight) - 3u;\n shaderMode = combined & 3u;\n texMode = combined >> 2u;\n }\n\n // Colored directional sun light — matches the procedural cube/sphere shader.\n let sunDir = normalize(frameLighting.sunDir);\n var ndotl = max(dot(N, sunDir), 0.0);\n // Toon shader override: quantize the sun term into discrete bands.\n if (shaderMode == 1u) {\n ndotl = floor(ndotl * 3.0 + 0.5) / 3.0;\n ndotl = max(ndotl, 0.15);\n }\n let sunDiffuse = ndotl * frameLighting.sunColor;\n\n // Hemisphere ambient: blend sky/ground based on normal.y (same as cube shader).\n let hemiMix = N.y * 0.5 + 0.5;\n let hemisphere = mix(frameLighting.groundAmbient, frameLighting.skyAmbient, hemiMix);\n let ambient = hemisphere * frameLighting.ambientIntensity;\n\n // Bindless albedo sample: index the material SSBO by materialIndex, then\n // sample the texture_2d_array page/layer the material points at.\n let arr = unpackArrayIndex(m.albedoTex);\n let layer = unpackLayerIndex(m.albedoTex);\n let texColor = sampleBindlessArray(arr, input.uv, layer);\n\n // All inputs are sRGB: texture (rgba8unorm), vertex color (white), and\n // baseColor (parser converts FBX linear DiffuseColor to sRGB). The swapchain\n // is non-sRGB (bgra8unorm), so output sRGB directly.\n var baseColor = texColor.rgb * input.color * m.baseColor.rgb;\n\n // ── Texture override modes ──\n // Wireframe: darken albedo, UV-grid lines overlaid after lighting.\n // Checker: two-tone checkerboard over the UVs.\n var texLine = 0.0;\n if (texMode == 1u) {\n let g = fract(input.uv * 6.0);\n let lx = min(g.x, 1.0 - g.x);\n let ly = min(g.y, 1.0 - g.y);\n texLine = 1.0 - smoothstep(0.0, 0.08, min(lx, ly));\n baseColor = baseColor * 0.15;\n } else if (texMode == 2u) {\n let cell = floor(input.uv * 8.0);\n let check = (cell.x + cell.y) - floor((cell.x + cell.y) * 0.5) * 2.0;\n baseColor = mix(baseColor * 0.35, baseColor * 1.4 + vec3<f32>(0.08), check);\n }\n\n // Channel-inspection modes — bypass lighting entirely for material review.\n // 3 = perturbed normals (normal-map contribution visible), 4 = unlit albedo\n // (texture/baseColor only), 5 = geometric normals (no normal map).\n if (texMode == 3u) { return vec4<f32>(N * 0.5 + vec3<f32>(0.5), 1.0); }\n if (texMode == 4u) { return vec4<f32>(baseColor, 1.0); }\n if (texMode == 5u) { return vec4<f32>(geometricN * 0.5 + vec3<f32>(0.5), 1.0); }\n\n var litColor = baseColor * (ambient + sunDiffuse);\n\n // Point lights (up to 8). Each light is 2 vec4s: (pos.xyz, radius) + (color.rgb, intensity).\n let plCount = frameLighting.pointLightCount;\n for (var i = 0u; i < plCount; i++) {\n let pl0 = frameLighting.pointLights[i * 2u];\n let pl1 = frameLighting.pointLights[i * 2u + 1u];\n let plPos = pl0.xyz;\n let plRadius = pl0.w;\n let plColor = pl1.xyz;\n let plIntensity = pl1.w;\n let L = plPos - input.worldPos;\n let dist = length(L);\n if (dist < plRadius) {\n let atten = 1.0 / (1.0 + dist * dist / (plRadius * plRadius));\n let ndotl_pl = max(dot(N, normalize(L)), 0.0);\n litColor += baseColor * plColor * plIntensity * atten * ndotl_pl;\n }\n }\n\n // Wireframe overlay: paint the UV-grid lines in a bright tint.\n if (texLine > 0.0) {\n litColor = mix(litColor, vec3<f32>(0.55, 0.95, 1.0), texLine * 0.9);\n }\n\n // Distance fog — anchored to the camera→model distance so it scales with\n // the subject instead of a fixed 400m absolute (a 200m model viewed from\n // 500m would otherwise fog to the ambient color entirely). Small models\n // near the camera keep the original ~400m reach.\n let modelDist = length(uniforms.cameraPos - uniforms.modelPos);\n let fogStart = max(modelDist * 1.5, 60.0);\n let fogEnd = max(modelDist * 4.0, 400.0);\n let dist = length(uniforms.cameraPos - input.worldPos);\n let fog = clamp(1.0 - (dist - fogStart) / (fogEnd - fogStart), 0.0, 1.0);\n var outColor = mix(frameLighting.skyAmbient, litColor, fog);\n\n // Outline shader override (shaderMode == 3): darken the body and add a\n // bright fresnel rim so the prop reads with a bold cel edge.\n if (shaderMode == 3u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 3.0);\n outColor = outColor * 0.7 + vec3<f32>(1.0, 0.95, 0.6) * smoothstep(0.35, 0.9, fresnel);\n }\n\n // Hologram shader override (shaderMode == 2): fresnel rim + scanline pulse\n // tinted toward the prop's own base color. Opaque output (the pipeline has\n // no blend state) but reads as a hologram.\n if (shaderMode == 2u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let holoBase = baseColor * vec3<f32>(0.3, 0.9, 1.1) + vec3<f32>(0.05, 0.2, 0.25);\n let holoRim = vec3<f32>(0.7, 1.0, 1.0);\n var holoCol = mix(holoBase, holoRim, fresnel) * pulse;\n holoCol = holoCol + holoRim * scan * 0.15 * fresnel;\n return vec4<f32>(holoCol, 1.0);\n }\n\n // Ghost hologram override (highlight == 1): render the prop as a\n // translucent-looking cyan shell with a fresnel rim and a vertical\n // scanline pulse so it's visually distinct from normally-grabbed (solid)\n // props. Opaque output (the pipeline has no blend state) but reads as a\n // hologram. Hover outline (highlight == 2) is handled by a separate\n // inverted-hull draw — see vs_outline / fs_outline below.\n if (uniforms.highlight > 0.5 && uniforms.highlight < 1.5) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let ghostBase = vec3<f32>(0.15, 0.75, 0.95);\n let ghostRim = vec3<f32>(0.7, 1.0, 1.0);\n var ghostCol = mix(ghostBase, ghostRim, fresnel) * pulse;\n ghostCol = ghostCol + ghostRim * scan * 0.15 * fresnel;\n return vec4<f32>(ghostCol, 1.0);\n }\n\n return vec4<f32>(outColor, 1.0);\n}\n\n// ── Mask entry point for post-process outline ──\n// Renders the model as solid white to a mask texture. Uses the same\n// vertex shader as the normal render (vs_main / vs_skinned).\n@fragment\nfn fs_mask() -> @location(0) vec4f {\n return vec4f(1.0, 1.0, 1.0, 1.0);\n}\n";
|
|
13
13
|
const log = createLogger();
|
|
14
14
|
export class ModelRenderer {
|
|
15
15
|
device;
|
|
@@ -50,9 +50,11 @@ export class ModelRenderer {
|
|
|
50
50
|
reusableUniforms = new Float32Array(64);
|
|
51
51
|
reusableUniformsU32 = new Uint32Array(this.reusableUniforms.buffer);
|
|
52
52
|
textureLoadVersion = new Map();
|
|
53
|
-
// Skinning: a shared storage buffer of bone matrices (
|
|
54
|
-
//
|
|
55
|
-
// draws only.
|
|
53
|
+
// Skinning: a shared storage buffer of bone matrices (mat4 each), updated
|
|
54
|
+
// per-frame via updateSkinMatrices/updateSkinMatricesFor. Bound at @group(1)
|
|
55
|
+
// for skinned draws only. Each skinned nodeId owns a region of the buffer —
|
|
56
|
+
// the draw's `boneBase` uniform selects it so multiple skinned models can be
|
|
57
|
+
// animated independently in one frame.
|
|
56
58
|
skinMatrixBuffer = null;
|
|
57
59
|
skinBindGroupLayout = null;
|
|
58
60
|
skinBindGroup = null;
|
|
@@ -60,6 +62,16 @@ export class ModelRenderer {
|
|
|
60
62
|
skinBindGroupSetThisFrame = false;
|
|
61
63
|
/** Current bone capacity (number of mat4s the skin buffer can hold). Grows on demand. */
|
|
62
64
|
boneCapacity = ModelRenderer.INITIAL_BONE_CAPACITY;
|
|
65
|
+
/** nodeId → reserved bone region (offset+count in bones). */
|
|
66
|
+
skinRegions = new Map();
|
|
67
|
+
/** nodeId → bone base index used by the skinning shader for that draw. */
|
|
68
|
+
skinBaseByNode = new Map();
|
|
69
|
+
/** Freed bone regions available for reuse (first-fit). */
|
|
70
|
+
skinFreeList = [];
|
|
71
|
+
/** Next never-allocated bone index (high-water mark). */
|
|
72
|
+
nextBoneOffset = 0;
|
|
73
|
+
/** Legacy caller region — updateSkinMatrices(m) without a nodeId. */
|
|
74
|
+
static DEFAULT_SKIN_NODE = "__skin_default";
|
|
63
75
|
// Bindless deps
|
|
64
76
|
bindless = null;
|
|
65
77
|
bindlessLayout = null;
|
|
@@ -757,6 +769,7 @@ export class ModelRenderer {
|
|
|
757
769
|
}
|
|
758
770
|
}
|
|
759
771
|
removeModel(nodeId) {
|
|
772
|
+
this.releaseSkinRegion(nodeId);
|
|
760
773
|
const resources = this.modelResources.get(nodeId);
|
|
761
774
|
if (resources) {
|
|
762
775
|
for (let i = 0; i < resources.length; i++) {
|
|
@@ -1004,36 +1017,77 @@ export class ModelRenderer {
|
|
|
1004
1017
|
return this.mipmapsEnabled;
|
|
1005
1018
|
}
|
|
1006
1019
|
/**
|
|
1007
|
-
* Upload bone skin matrices for the current frame
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
* small for the current skeleton, it is recreated (grown) automatically.
|
|
1011
|
-
* Call once per frame before render() for skinned models.
|
|
1020
|
+
* Upload bone skin matrices for the current frame into the shared default
|
|
1021
|
+
* region. Kept for single-avatar callers — equivalent to
|
|
1022
|
+
* updateSkinMatricesFor("__skin_default", matrices).
|
|
1012
1023
|
*/
|
|
1013
1024
|
updateSkinMatrices(matrices) {
|
|
1025
|
+
this.updateSkinMatricesFor(ModelRenderer.DEFAULT_SKIN_NODE, matrices);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Upload bone skin matrices for one nodeId's reserved region. The region is
|
|
1029
|
+
* (re)allocated automatically — freed regions from removeModel() are reused
|
|
1030
|
+
* first-fit. Render() looks up the node's region offset and passes it to the
|
|
1031
|
+
* skinning shader via the `boneBase` uniform, so multiple skinned models
|
|
1032
|
+
* animate independently.
|
|
1033
|
+
*/
|
|
1034
|
+
updateSkinMatricesFor(nodeId, matrices) {
|
|
1014
1035
|
if (!this.skinMatrixBuffer)
|
|
1015
1036
|
return;
|
|
1016
1037
|
const requiredBones = matrices.length / 16;
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
this.
|
|
1022
|
-
this.
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1038
|
+
let region = this.skinRegions.get(nodeId);
|
|
1039
|
+
if (!region || region.count < requiredBones) {
|
|
1040
|
+
if (region)
|
|
1041
|
+
this.skinFreeList.push(region);
|
|
1042
|
+
region = this.allocSkinRegion(requiredBones);
|
|
1043
|
+
this.skinRegions.set(nodeId, region);
|
|
1044
|
+
}
|
|
1045
|
+
this.skinBaseByNode.set(nodeId, region.offset);
|
|
1046
|
+
this.device.queue.writeBuffer(this.skinMatrixBuffer, region.offset * 64, matrices);
|
|
1047
|
+
}
|
|
1048
|
+
/** Allocate a bone region for `count` bones — free-list first, then bump
|
|
1049
|
+
* allocation; grows the shared buffer when the capacity is exceeded. */
|
|
1050
|
+
allocSkinRegion(count) {
|
|
1051
|
+
for (let i = 0; i < this.skinFreeList.length; i++) {
|
|
1052
|
+
const f = this.skinFreeList[i];
|
|
1053
|
+
if (f.count >= count) {
|
|
1054
|
+
this.skinFreeList.splice(i, 1);
|
|
1055
|
+
return { offset: f.offset, count: f.count };
|
|
1033
1056
|
}
|
|
1034
|
-
this.skinBindGroupSetThisFrame = false; // force re-bind
|
|
1035
1057
|
}
|
|
1036
|
-
this.
|
|
1058
|
+
const needed = this.nextBoneOffset + count;
|
|
1059
|
+
if (needed > this.boneCapacity)
|
|
1060
|
+
this.growSkinBuffer(needed);
|
|
1061
|
+
const region = { offset: this.nextBoneOffset, count };
|
|
1062
|
+
this.nextBoneOffset += count;
|
|
1063
|
+
return region;
|
|
1064
|
+
}
|
|
1065
|
+
/** Recreate the skin matrix buffer at a larger capacity and rebind group(1).
|
|
1066
|
+
* Region offsets stay valid — callers rewrite their matrices next frame. */
|
|
1067
|
+
growSkinBuffer(minBones) {
|
|
1068
|
+
const newCapacity = Math.max(minBones, this.boneCapacity * 2);
|
|
1069
|
+
this.skinMatrixBuffer?.destroy();
|
|
1070
|
+
this.skinMatrixBuffer = this.device.createBuffer({
|
|
1071
|
+
size: newCapacity * 64,
|
|
1072
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1073
|
+
});
|
|
1074
|
+
this.boneCapacity = newCapacity;
|
|
1075
|
+
if (this.skinBindGroupLayout) {
|
|
1076
|
+
this.skinBindGroup = this.device.createBindGroup({
|
|
1077
|
+
layout: this.skinBindGroupLayout,
|
|
1078
|
+
entries: [{ binding: 0, resource: { buffer: this.skinMatrixBuffer } }],
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
this.skinBindGroupSetThisFrame = false; // force re-bind
|
|
1082
|
+
}
|
|
1083
|
+
/** Release a node's bone region back to the free list. */
|
|
1084
|
+
releaseSkinRegion(nodeId) {
|
|
1085
|
+
const region = this.skinRegions.get(nodeId);
|
|
1086
|
+
if (!region)
|
|
1087
|
+
return;
|
|
1088
|
+
this.skinRegions.delete(nodeId);
|
|
1089
|
+
this.skinBaseByNode.delete(nodeId);
|
|
1090
|
+
this.skinFreeList.push(region);
|
|
1037
1091
|
}
|
|
1038
1092
|
render(passEncoder, nodeId, position, rotation, scale, instanceIndex = 0,
|
|
1039
1093
|
/** Per-draw highlight: 0 = normal shading, 1 = ghost hologram (cyan),
|
|
@@ -1132,6 +1186,12 @@ export class ModelRenderer {
|
|
|
1132
1186
|
uniforms[40] = 0.0;
|
|
1133
1187
|
uniforms[41] = 0.0;
|
|
1134
1188
|
uniforms[42] = 0.0;
|
|
1189
|
+
// boneBase (u32, slot 44) — index of this node's bone region inside the
|
|
1190
|
+
// shared skinMatrices storage buffer. Only read by vs_skinned.
|
|
1191
|
+
this.reusableUniformsU32[44] =
|
|
1192
|
+
this.skinBaseByNode.get(nodeId)
|
|
1193
|
+
?? this.skinBaseByNode.get(ModelRenderer.DEFAULT_SKIN_NODE)
|
|
1194
|
+
?? 0;
|
|
1135
1195
|
this.device.queue.writeBuffer(this.uniformBuffer, uniformOffset, uniforms);
|
|
1136
1196
|
// Select the skinned pipeline + skin vertex buffer for skinned meshes,
|
|
1137
1197
|
// otherwise the standard non-skinned pipeline.
|
|
@@ -22,6 +22,14 @@ struct Uniforms {
|
|
|
22
22
|
// Inverted-hull outline parameters (used by vs_outline / fs_outline only).
|
|
23
23
|
outlineWidth: f32,
|
|
24
24
|
outlineColor: vec3<f32>,
|
|
25
|
+
// Base index of this draw's bone region inside the shared skinMatrices
|
|
26
|
+
// storage buffer (group(1)). Written by ModelRenderer from the node's
|
|
27
|
+
// reserved region — lets multiple skinned models animate independently.
|
|
28
|
+
// Only read by vs_skinned.
|
|
29
|
+
boneBase: u32,
|
|
30
|
+
_padSkin0: u32,
|
|
31
|
+
_padSkin1: u32,
|
|
32
|
+
_padSkin2: u32,
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
|
|
@@ -165,10 +173,11 @@ fn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {
|
|
|
165
173
|
// If all weights are zero (vertex has no bone assignment), return identity so
|
|
166
174
|
// the vertex renders at its baked position instead of collapsing to the origin.
|
|
167
175
|
fn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {
|
|
168
|
-
let
|
|
169
|
-
|
|
170
|
-
+ skinMatrices[j.
|
|
171
|
-
+ skinMatrices[j.
|
|
176
|
+
let base = uniforms.boneBase;
|
|
177
|
+
let m = skinMatrices[base + j.x] * w.x
|
|
178
|
+
+ skinMatrices[base + j.y] * w.y
|
|
179
|
+
+ skinMatrices[base + j.z] * w.z
|
|
180
|
+
+ skinMatrices[base + j.w] * w.w;
|
|
172
181
|
let wsum = w.x + w.y + w.z + w.w;
|
|
173
182
|
if (wsum < 0.001) {
|
|
174
183
|
return mat4x4<f32>(
|
|
@@ -14,3 +14,4 @@ export { AuthorityManager } from "./authority.js";
|
|
|
14
14
|
// Engine library descriptor (declarative GameModule wiring)
|
|
15
15
|
export { NetworkClientTok, NetworkingLib } from "./library.js";
|
|
16
16
|
export { LanDiscovery, MdnsBrowser } from "./discovery.js";
|
|
17
|
+
export { createWebSocketServer } from "./ws-server.js";
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ws-server.ts — minimal tri-runtime WebSocket server for host-authoritative
|
|
3
|
+
// LAN/WAN sessions. The networking library ships client transports
|
|
4
|
+
// (WebSocketTransport, WebRTCTransport) but had no listener — this fills the
|
|
5
|
+
// host side so a game instance can accept direct connections without any
|
|
6
|
+
// external service.
|
|
7
|
+
//
|
|
8
|
+
// Runtime selection is capability-based (see platform/runtime.ts):
|
|
9
|
+
// Bun → Bun.serve({ fetch, websocket })
|
|
10
|
+
// Deno → Deno.serve + Deno.upgradeWebSocket
|
|
11
|
+
// Node → node:http + a hand-rolled RFC6455 handshake/framer (pure JS,
|
|
12
|
+
// no native deps — also the fallback for any runtime with node:http)
|
|
13
|
+
//
|
|
14
|
+
// Semantics intentionally mirror the browser WebSocket surface: text frames
|
|
15
|
+
// arrive as `string`, binary frames as `Uint8Array`. Ping/pong/close and
|
|
16
|
+
// fragmented-frame reassembly are handled internally. Message cap matches
|
|
17
|
+
// WebSocketTransport's 16 MiB.
|
|
18
|
+
// ============================================================================
|
|
19
|
+
import { createLogger } from "@downdraft/engine/util/logger";
|
|
20
|
+
const log = createLogger();
|
|
21
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
22
|
+
const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
|
|
23
|
+
function makeServerBase(handlers) {
|
|
24
|
+
const conns = new Set();
|
|
25
|
+
let nextId = 1;
|
|
26
|
+
const deliver = (conn, data) => {
|
|
27
|
+
const size = typeof data === "string" ? data.length : data.byteLength;
|
|
28
|
+
if (size > MAX_MESSAGE_SIZE) {
|
|
29
|
+
conn.close(1009, "message too large");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
handlers.onMessage?.(conn, data);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
log.error("WsServer", `onMessage threw: ${e}`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const drop = (conn, code, reason) => {
|
|
40
|
+
if (!conns.delete(conn))
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
handlers.onClose?.(conn, code, reason);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
log.error("WsServer", `onClose threw: ${e}`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return { conns, nextId: () => nextId++, deliver, drop };
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Bun — Bun.serve with the `websocket` handler map
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function startBunServer(opts) {
|
|
55
|
+
const Bun = globalThis.Bun;
|
|
56
|
+
const base = makeServerBase(opts);
|
|
57
|
+
const server = Bun.serve({
|
|
58
|
+
port: opts.port,
|
|
59
|
+
hostname: opts.host ?? "0.0.0.0",
|
|
60
|
+
fetch(req, srv) {
|
|
61
|
+
const ip = srv.requestIP(req);
|
|
62
|
+
const conn = {
|
|
63
|
+
id: base.nextId(),
|
|
64
|
+
remoteAddress: `${ip?.address ?? "?"}:${ip?.port ?? "?"}`,
|
|
65
|
+
raw: null,
|
|
66
|
+
send(data) { if (this.raw)
|
|
67
|
+
this.raw.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data); },
|
|
68
|
+
close(code = 1000, reason = "") { if (this.raw)
|
|
69
|
+
this.raw.close(code, reason); },
|
|
70
|
+
};
|
|
71
|
+
return srv.upgrade(req, { data: { conn } }) ? undefined : new Response("WebSocket upgrade required", { status: 426 });
|
|
72
|
+
},
|
|
73
|
+
websocket: {
|
|
74
|
+
open(ws) {
|
|
75
|
+
const conn = ws.data.conn;
|
|
76
|
+
conn.raw = ws;
|
|
77
|
+
base.conns.add(conn);
|
|
78
|
+
try {
|
|
79
|
+
opts.onConnection?.(conn);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
log.error("WsServer", `onConnection threw: ${e}`);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
message(ws, msg) {
|
|
86
|
+
const conn = ws.data.conn;
|
|
87
|
+
base.deliver(conn, typeof msg === "string" ? msg : new Uint8Array(msg));
|
|
88
|
+
},
|
|
89
|
+
close(ws, code, reason) {
|
|
90
|
+
base.drop(ws.data.conn, code, reason);
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return Promise.resolve({
|
|
95
|
+
port: server.port,
|
|
96
|
+
host: opts.host ?? "0.0.0.0",
|
|
97
|
+
connections: () => [...base.conns],
|
|
98
|
+
async close() {
|
|
99
|
+
[...base.conns].forEach((c) => c.close(1001, "server shutdown"));
|
|
100
|
+
await server.stop(true);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Deno — Deno.serve + Deno.upgradeWebSocket
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
function startDenoServer(opts) {
|
|
108
|
+
const Deno = globalThis.Deno;
|
|
109
|
+
const base = makeServerBase(opts);
|
|
110
|
+
const server = Deno.serve({ port: opts.port, hostname: opts.host ?? "0.0.0.0" }, (req, info) => {
|
|
111
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
112
|
+
return new Response("WebSocket upgrade required", { status: 426 });
|
|
113
|
+
}
|
|
114
|
+
const { socket, response } = Deno.upgradeWebSocket(req);
|
|
115
|
+
const conn = {
|
|
116
|
+
id: base.nextId(),
|
|
117
|
+
remoteAddress: info?.remoteAddr ? `${info.remoteAddr.hostname}:${info.remoteAddr.port}` : "?",
|
|
118
|
+
raw: socket,
|
|
119
|
+
send(data) { try {
|
|
120
|
+
socket.send(data);
|
|
121
|
+
}
|
|
122
|
+
catch { /* closing */ } },
|
|
123
|
+
close(code = 1000, reason = "") { try {
|
|
124
|
+
socket.close(code, reason);
|
|
125
|
+
}
|
|
126
|
+
catch { /* already closed */ } },
|
|
127
|
+
};
|
|
128
|
+
socket.binaryType = "arraybuffer";
|
|
129
|
+
socket.onopen = () => {
|
|
130
|
+
base.conns.add(conn);
|
|
131
|
+
try {
|
|
132
|
+
opts.onConnection?.(conn);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
log.error("WsServer", `onConnection threw: ${e}`);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
socket.onmessage = (e) => {
|
|
139
|
+
base.deliver(conn, typeof e.data === "string" ? e.data : new Uint8Array(e.data));
|
|
140
|
+
};
|
|
141
|
+
socket.onclose = (e) => base.drop(conn, e.code, e.reason);
|
|
142
|
+
socket.onerror = () => base.drop(conn, 1006, "socket error");
|
|
143
|
+
return response;
|
|
144
|
+
});
|
|
145
|
+
const bound = server.addr;
|
|
146
|
+
return Promise.resolve({
|
|
147
|
+
port: bound.port,
|
|
148
|
+
host: bound.hostname,
|
|
149
|
+
connections: () => [...base.conns],
|
|
150
|
+
async close() {
|
|
151
|
+
[...base.conns].forEach((c) => c.close(1001, "server shutdown"));
|
|
152
|
+
await server.shutdown();
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Node (and generic fallback) — node:http + hand-rolled RFC6455
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
/** Encode one server→client frame. Server frames are never masked. */
|
|
160
|
+
function encodeWsFrame(opcode, payload) {
|
|
161
|
+
const len = payload.byteLength;
|
|
162
|
+
let header;
|
|
163
|
+
if (len < 126) {
|
|
164
|
+
header = new Uint8Array(2);
|
|
165
|
+
header[1] = len;
|
|
166
|
+
}
|
|
167
|
+
else if (len <= 0xffff) {
|
|
168
|
+
header = new Uint8Array(4);
|
|
169
|
+
header[1] = 126;
|
|
170
|
+
header[2] = len >>> 8;
|
|
171
|
+
header[3] = len & 0xff;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
header = new Uint8Array(10);
|
|
175
|
+
header[1] = 127;
|
|
176
|
+
const dv = new DataView(header.buffer);
|
|
177
|
+
dv.setUint32(2, Math.floor(len / 0x100000000));
|
|
178
|
+
dv.setUint32(6, len >>> 0);
|
|
179
|
+
}
|
|
180
|
+
header[0] = 0x80 | (opcode & 0x0f);
|
|
181
|
+
const out = new Uint8Array(header.length + len);
|
|
182
|
+
out.set(header, 0);
|
|
183
|
+
out.set(payload, header.length);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
const OP_CONT = 0x0, OP_TEXT = 0x1, OP_BIN = 0x2, OP_CLOSE = 0x8, OP_PING = 0x9, OP_PONG = 0xa;
|
|
187
|
+
async function startNodeServer(opts) {
|
|
188
|
+
const http = await import("node:http");
|
|
189
|
+
const crypto = await import("node:crypto");
|
|
190
|
+
const base = makeServerBase(opts);
|
|
191
|
+
const httpServer = http.createServer((_req, res) => {
|
|
192
|
+
res.writeHead(426, { "Content-Type": "text/plain" });
|
|
193
|
+
res.end("WebSocket upgrade required");
|
|
194
|
+
});
|
|
195
|
+
httpServer.on("upgrade", (req, socket) => {
|
|
196
|
+
socket.setNoDelay(true);
|
|
197
|
+
const key = req.headers["sec-websocket-key"];
|
|
198
|
+
const upgrade = req.headers.upgrade?.toLowerCase();
|
|
199
|
+
if (upgrade !== "websocket" || typeof key !== "string") {
|
|
200
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
201
|
+
socket.destroy();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const accept = crypto.createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
205
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
206
|
+
"Upgrade: websocket\r\n" +
|
|
207
|
+
"Connection: Upgrade\r\n" +
|
|
208
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
209
|
+
let closed = false;
|
|
210
|
+
const conn = {
|
|
211
|
+
id: base.nextId(),
|
|
212
|
+
remoteAddress: `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`,
|
|
213
|
+
raw: socket,
|
|
214
|
+
send(data) {
|
|
215
|
+
if (closed || socket.destroyed)
|
|
216
|
+
return;
|
|
217
|
+
if (typeof data === "string") {
|
|
218
|
+
socket.write(Buffer.from(encodeWsFrame(OP_TEXT, Buffer.from(data, "utf8"))));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
222
|
+
socket.write(Buffer.from(encodeWsFrame(OP_BIN, bytes)));
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
close(code = 1000, reason = "") {
|
|
226
|
+
if (closed)
|
|
227
|
+
return;
|
|
228
|
+
closed = true;
|
|
229
|
+
const payload = new Uint8Array(2 + reason.length);
|
|
230
|
+
payload[0] = code >>> 8;
|
|
231
|
+
payload[1] = code & 0xff;
|
|
232
|
+
payload.set(new TextEncoder().encode(reason), 2);
|
|
233
|
+
try {
|
|
234
|
+
socket.write(Buffer.from(encodeWsFrame(OP_CLOSE, payload)));
|
|
235
|
+
socket.end();
|
|
236
|
+
}
|
|
237
|
+
catch { /* socket already gone */ }
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
// ── Incremental frame parser ──
|
|
241
|
+
let buf = Buffer.alloc(0);
|
|
242
|
+
let fragOp = -1;
|
|
243
|
+
const frags = [];
|
|
244
|
+
const finishMessage = (op, payload) => {
|
|
245
|
+
if (payload.byteLength > MAX_MESSAGE_SIZE) {
|
|
246
|
+
conn.close(1009, "message too large");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (op === OP_TEXT)
|
|
250
|
+
base.deliver(conn, payload.toString("utf8"));
|
|
251
|
+
else
|
|
252
|
+
base.deliver(conn, new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength));
|
|
253
|
+
};
|
|
254
|
+
const handleFrame = (fin, op, payload) => {
|
|
255
|
+
if (op === OP_CLOSE) {
|
|
256
|
+
const code = payload.byteLength >= 2 ? payload.readUInt16BE(0) : 1000;
|
|
257
|
+
const reason = payload.byteLength > 2 ? payload.subarray(2).toString("utf8") : "";
|
|
258
|
+
conn.close(code, reason);
|
|
259
|
+
base.drop(conn, code, reason);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (op === OP_PING) {
|
|
263
|
+
if (!closed && !socket.destroyed)
|
|
264
|
+
socket.write(Buffer.from(encodeWsFrame(OP_PONG, payload)));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (op === OP_PONG)
|
|
268
|
+
return;
|
|
269
|
+
if (op === OP_CONT && fragOp < 0) {
|
|
270
|
+
conn.close(1002, "unexpected continuation");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if ((op === OP_TEXT || op === OP_BIN) && fragOp >= 0) {
|
|
274
|
+
conn.close(1002, "interleaved frame");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (op !== OP_CONT)
|
|
278
|
+
fragOp = op;
|
|
279
|
+
frags.push(payload);
|
|
280
|
+
if (!fin)
|
|
281
|
+
return;
|
|
282
|
+
const full = Buffer.concat(frags);
|
|
283
|
+
frags.length = 0;
|
|
284
|
+
const finalOp = fragOp;
|
|
285
|
+
fragOp = -1;
|
|
286
|
+
finishMessage(finalOp, full);
|
|
287
|
+
};
|
|
288
|
+
socket.on("data", (chunk) => {
|
|
289
|
+
buf = buf.byteLength ? Buffer.concat([buf, chunk]) : chunk;
|
|
290
|
+
// Parse as many complete frames as the buffer holds.
|
|
291
|
+
for (;;) {
|
|
292
|
+
if (buf.byteLength < 2)
|
|
293
|
+
return;
|
|
294
|
+
const b0 = buf[0], b1 = buf[1];
|
|
295
|
+
const fin = (b0 & 0x80) !== 0;
|
|
296
|
+
const op = b0 & 0x0f;
|
|
297
|
+
const masked = (b1 & 0x80) !== 0;
|
|
298
|
+
let len = b1 & 0x7f;
|
|
299
|
+
let off = 2;
|
|
300
|
+
if (len === 126) {
|
|
301
|
+
if (buf.byteLength < off + 2)
|
|
302
|
+
return;
|
|
303
|
+
len = buf.readUInt16BE(off);
|
|
304
|
+
off += 2;
|
|
305
|
+
}
|
|
306
|
+
else if (len === 127) {
|
|
307
|
+
if (buf.byteLength < off + 8)
|
|
308
|
+
return;
|
|
309
|
+
const hi = buf.readUInt32BE(off), lo = buf.readUInt32BE(off + 4);
|
|
310
|
+
off += 8;
|
|
311
|
+
if (hi > 0 || lo > MAX_MESSAGE_SIZE) {
|
|
312
|
+
conn.close(1009, "message too large");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
len = lo;
|
|
316
|
+
}
|
|
317
|
+
const maskLen = masked ? 4 : 0;
|
|
318
|
+
if (buf.byteLength < off + maskLen + len)
|
|
319
|
+
return;
|
|
320
|
+
let payload = buf.subarray(off + maskLen, off + maskLen + len);
|
|
321
|
+
if (masked) {
|
|
322
|
+
const mask = buf.subarray(off, off + 4);
|
|
323
|
+
const unmasked = Buffer.allocUnsafe(payload.byteLength);
|
|
324
|
+
for (let i = 0; i < payload.byteLength; i++)
|
|
325
|
+
unmasked[i] = payload[i] ^ mask[i & 3];
|
|
326
|
+
payload = unmasked;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
payload = Buffer.from(payload); // detach — subarray aliases buf
|
|
330
|
+
}
|
|
331
|
+
buf = buf.subarray(off + maskLen + len);
|
|
332
|
+
handleFrame(fin, op, payload);
|
|
333
|
+
if (closed)
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
const onSocketEnd = () => base.drop(conn, 1006, "socket closed");
|
|
338
|
+
socket.on("close", onSocketEnd);
|
|
339
|
+
socket.on("error", onSocketEnd);
|
|
340
|
+
base.conns.add(conn);
|
|
341
|
+
try {
|
|
342
|
+
opts.onConnection?.(conn);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
log.error("WsServer", `onConnection threw: ${e}`);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
await new Promise((resolve, reject) => {
|
|
349
|
+
httpServer.once("error", reject);
|
|
350
|
+
httpServer.listen(opts.port, opts.host ?? "0.0.0.0", () => {
|
|
351
|
+
httpServer.removeListener("error", reject);
|
|
352
|
+
resolve();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
const addr = httpServer.address();
|
|
356
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
357
|
+
return {
|
|
358
|
+
port: boundPort,
|
|
359
|
+
host: opts.host ?? "0.0.0.0",
|
|
360
|
+
connections: () => [...base.conns],
|
|
361
|
+
async close() {
|
|
362
|
+
[...base.conns].forEach((c) => c.close(1001, "server shutdown"));
|
|
363
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
364
|
+
// httpServer.close waits for open sockets; force-destroy stragglers.
|
|
365
|
+
[...base.conns].forEach((c) => c.raw.destroy());
|
|
366
|
+
base.conns.clear();
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Entry point
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
/**
|
|
374
|
+
* Start a WebSocket server. Resolves once the socket is bound and listening.
|
|
375
|
+
* Runtime is picked by capability: Bun.serve → Deno.serve → node:http.
|
|
376
|
+
*/
|
|
377
|
+
export function createWebSocketServer(opts) {
|
|
378
|
+
const g = globalThis;
|
|
379
|
+
const bun = g.Bun;
|
|
380
|
+
if (typeof bun?.serve === "function")
|
|
381
|
+
return startBunServer(opts);
|
|
382
|
+
const deno = g.Deno;
|
|
383
|
+
if (typeof deno?.serve === "function" && typeof deno.upgradeWebSocket === "function") {
|
|
384
|
+
return startDenoServer(opts);
|
|
385
|
+
}
|
|
386
|
+
return startNodeServer(opts);
|
|
387
|
+
}
|