@downdraft/engine 0.3.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/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/render/canvas-resize-watcher.ts +1 -10
- package/core/src/render/game-renderer.ts +40 -18
- 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/ui-font-scale.ts +5 -38
- package/core/src/worker/rpc.ts +65 -0
- package/dist/.dd-manifest.json +859 -884
- 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/module/host.js +3 -6
- package/dist/core/src/render/canvas-resize-watcher.js +1 -10
- package/dist/core/src/render/game-renderer.js +44 -18
- 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/ui-font-scale.js +5 -38
- package/dist/core/src/worker/rpc.js +59 -0
- package/dist/libraries/blitz-ui/src/native-module.js +8 -1
- 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/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/test/src/vision-test.js +1 -3
- package/libraries/blitz-ui/src/native-module.ts +8 -1
- 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/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/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
|
@@ -515,8 +515,6 @@ async function collectWorkers(ctx) {
|
|
|
515
515
|
kind: "kv",
|
|
516
516
|
name: `Worker manifest (v${m.manifestVersion ?? "?"})`,
|
|
517
517
|
rows: [
|
|
518
|
-
kv("Panels", (m.panels ?? []).map((p) => p.id ?? p.name ?? "?").join(", ") || "(none)"),
|
|
519
|
-
kv("Toggles", (m.toggles ?? []).map((t) => t.id ?? "?").join(", ") || "(none)"),
|
|
520
518
|
kv("Data feeds", (m.dataFeeds ?? []).map((f) => `${f.name}@${f.writeRateHz}hz`).join(", ") || "(none)"),
|
|
521
519
|
kv("Commands", (m.commands ?? []).join(", ") || "(none)"),
|
|
522
520
|
],
|
|
@@ -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";
|