@hology/core 0.0.272 → 0.0.274
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/dist/effects/sequence/sequence-player.js +1 -1
- package/dist/effects/vfx/gpu-sprite-runtime.js +1 -1
- package/dist/effects/vfx/gpu-vfx-world-service.js +1 -1
- package/dist/effects/vfx/rates.d.ts +2 -0
- package/dist/effects/vfx/rates.js +1 -1
- package/dist/effects/vfx/vfx-actor.d.ts +2 -1
- package/dist/effects/vfx/vfx-actor.js +1 -1
- package/dist/effects/vfx/vfx-asset.d.ts +10 -1
- package/dist/effects/vfx/vfx-backend-partition.d.ts +8 -0
- package/dist/effects/vfx/vfx-backend-partition.js +4 -0
- package/dist/effects/vfx/vfx-materializer.js +1 -1
- package/dist/effects/vfx/vfx-render-preparation.js +1 -1
- package/dist/effects/vfx/vfx-renderers.d.ts +27 -2
- package/dist/effects/vfx/vfx-renderers.js +1 -1
- package/dist/gameplay/actors/builtin/components/character/character-animation.d.ts +2 -0
- package/dist/gameplay/actors/builtin/components/character/character-animation.js +1 -1
- package/dist/gameplay/actors/builtin/components/character/character-movement.d.ts +19 -0
- package/dist/gameplay/actors/builtin/components/character/character-movement.js +1 -1
- package/dist/gameplay/actors/builtin/water-spline-volume-actor.d.ts +10 -0
- package/dist/gameplay/actors/builtin/water-spline-volume-actor.js +1 -1
- package/dist/gameplay/actors/builtin/water-volume-actor.d.ts +3 -1
- package/dist/gameplay/actors/builtin/water-volume-actor.js +1 -1
- package/dist/gameplay/actors/camera/first-person-camera-component.js +1 -1
- package/dist/gameplay/actors/camera/third-person-camera-component.d.ts +7 -0
- package/dist/gameplay/actors/camera/third-person-camera-component.js +1 -1
- package/dist/gameplay/ai/behavior-tree/move.d.ts +5 -0
- package/dist/gameplay/ai/behavior-tree/move.js +1 -1
- package/dist/gameplay/ai/behavior-tree/move.test.d.ts +2 -0
- package/dist/gameplay/ai/behavior-tree/move.test.js +4 -0
- package/dist/gameplay/animation/root-motion.js +1 -1
- package/dist/gameplay/input/input-service.d.ts +9 -1
- package/dist/gameplay/input/input-service.js +1 -1
- package/dist/gameplay/services/physics/physics-system.d.ts +2 -0
- package/dist/gameplay/services/physics/physics-system.js +1 -1
- package/dist/gameplay/services/render.d.ts +4 -1
- package/dist/gameplay/services/render.js +1 -1
- package/dist/gameplay/services/world.d.ts +5 -0
- package/dist/rendering/node-material-webgpu-resolver.js +1 -1
- package/dist/rendering/webgpu-experimental-backend.d.ts +4 -2
- package/dist/rendering/webgpu-experimental-backend.js +1 -1
- package/dist/rendering.d.ts +17 -2
- package/dist/rendering.js +1 -1
- package/dist/scene/asset-resource-loader.d.ts +4 -0
- package/dist/scene/asset-resource-loader.js +1 -1
- package/dist/scene/collision/collision-shape-import.js +1 -1
- package/dist/scene/collision/rapier-shape.js +1 -1
- package/dist/scene/landscape/landscape-geometry.d.ts +24 -0
- package/dist/scene/landscape/landscape-geometry.js +4 -0
- package/dist/scene/landscape/landscape-manager.d.ts +1 -0
- package/dist/scene/landscape/landscape-manager.js +1 -1
- package/dist/scene/materializer.d.ts +7 -1
- package/dist/scene/materializer.js +1 -1
- package/dist/scene/scene-data-service.d.ts +2 -1
- package/dist/scene/scene-data-service.js +1 -1
- package/dist/scene/sky.d.ts +2 -0
- package/dist/scene/sky.js +1 -1
- package/dist/shader/graph/compiler.js +1 -1
- package/dist/shader/graph/registry.js +1 -1
- package/dist/shader/parameter.js +1 -1
- package/dist/shader/sprite-shader.js +1 -1
- package/dist/shader-nodes/depth.d.ts +6 -4
- package/dist/shader-nodes/depth.js +1 -1
- package/dist/shader-nodes/index.d.ts +2 -1
- package/dist/shader-nodes/index.js +1 -1
- package/dist/shader-nodes/surface-reflection.d.ts +9 -0
- package/dist/shader-nodes/surface-reflection.js +4 -0
- package/dist/shader-nodes/water-volume.d.ts +6 -2
- package/dist/shader-nodes/water-volume.js +1 -1
- package/dist/test/asset-resource-loader-texture-array.test.js +1 -1
- package/dist/test/character-movement-solve.test.js +1 -1
- package/dist/test/collision-shape-import.test.js +1 -1
- package/dist/test/first-person-camera-component.test.js +1 -1
- package/dist/test/frame-stats.test.js +1 -1
- package/dist/test/gpu-sprite-compiler.test.js +1 -1
- package/dist/test/input.test.js +1 -1
- package/dist/test/landscape-geometry.test.d.ts +2 -0
- package/dist/test/landscape-geometry.test.js +4 -0
- package/dist/test/material-assignment.test.js +1 -1
- package/dist/test/materializer-dynamic-transform.test.js +1 -1
- package/dist/test/materializer-prefetch.test.js +1 -1
- package/dist/test/net-character-movement.test.js +1 -1
- package/dist/test/node-material-webgpu-resolver.test.js +1 -1
- package/dist/test/parameter-definition.test.js +1 -1
- package/dist/test/physics-hitstop.test.d.ts +2 -0
- package/dist/test/physics-hitstop.test.js +4 -0
- package/dist/test/physics-ray-object.test.js +1 -1
- package/dist/test/post-process-effect.test.js +1 -1
- package/dist/test/rapier-trimesh.test.d.ts +2 -0
- package/dist/test/rapier-trimesh.test.js +4 -0
- package/dist/test/sequence-animation-retiming.test.js +1 -1
- package/dist/test/sequence-vfx.test.js +1 -1
- package/dist/test/surface-reflection.test.d.ts +2 -0
- package/dist/test/surface-reflection.test.js +4 -0
- package/dist/test/vfx-backend-partition.test.d.ts +2 -0
- package/dist/test/vfx-backend-partition.test.js +4 -0
- package/dist/test/vfx-playback-transform.test.js +1 -1
- package/dist/test/vfx-render-preparation.test.js +1 -1
- package/dist/test/water-spline-volume-actor.test.js +1 -1
- package/dist/utils/mesh.d.ts +1 -1
- package/dist/utils/mesh.js +1 -1
- package/dist/utils/three/frame-stats.d.ts +8 -0
- package/dist/utils/three/frame-stats.js +1 -1
- package/package.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{Vector2 as e}from"three";import{cross as t,dot as o,float as r,FloatExpressionNode as n,FloatNode as
|
|
1
|
+
import{Vector2 as e}from"three";import{cross as t,dot as o,float as r,FloatExpressionNode as n,FloatNode as s,inverse as c,inversesqrt as i,isWgslCompiler as p,isWgslFragmentCompiler as a,isWgslFullscreenFragmentCompiler as l,Mat4Node as u,max as m,select as d,shaderFunction as h,texture2d as x,transformed as f,uniformFloat as v,uniforms as y,uniformSampler2d as g,uniformVec2 as w,unpackRGBToNormal as U,varying as _,vec2 as P,Vec2ExpressionNode as V,Vec2Node as $,Vec3Node as k,vec4 as N,Vec4Node as S}from"three-shader-graph";import{createPlaceholderTexture as D}from"../utils/three/placeholder-texture.js";export function supportsDepthTextureExtension(e){return!0}export const depthUniformName="hology_depth_map";export const resolutionUniformName="hology_resolution";export const nearUniformName="hology_camera_near";export const farUniformName="hology_camera_far";export const sceneNormalUniformName="hology_scene_normal_map";export const depthSampler=g(depthUniformName,D());const j=v(nearUniformName,.5),F=v(farUniformName,500),W=r(1e-6);function b(e){return d(e.gte(r(0)),m(e,W),m(e.multiply(-1),W).multiply(-1))}function z(e){return r(2).multiply(j).multiply(F).divide(b(F.add(j).subtract(r(2).multiply(e).subtract(r(1)).multiply(F.subtract(j)))))}new class extends S{constructor(){super(...arguments),this.k="31u50"}compile(e){return{pars:`\n const float UnpackDownscale_${this.k} = 255. / 256.; // 0..1 -> fraction (excluding 1)\n const vec4 PackFactors_${this.k} = vec4( 1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0 );\n const vec4 UnpackFactors_${this.k} = vec4( UnpackDownscale_${this.k} / PackFactors_${this.k}.rgb, 1.0 / PackFactors_${this.k}.a );\n `,out:`UnpackFactors_${this.k}`}}};function E(e){return x(depthSampler,e).r}export function sampleSceneDepth(e){return E(e)}export function sampleSceneLinearEyeDepth(e){return z(E(e))}const M=y.projectionMatrix.multiplyVec(f.mvPosition).zw,C=_(M);export const highPrecisionEyeDepth=C.x.multiply(.5).divide(b(C.y)).add(.5);const L=new n("gl_FragCoord.z");export const fragmentLinearEyeDepth=z(L);export const resolution=w("hology_resolution",new e(250,1e3));export const screenUV=new class extends ${compile(e){if(l(e))return{out:e.get(new V("uv"))};return{out:`(${e.get(new V("gl_FragCoord.xy"))} / max(${e.get(resolution)}, ${p(e)?e.construct("vec2",["1.0","1.0"]):"vec2(1.0)"}))`}}};export const linearEyeDepth=z(E(screenUV));function I(e){const t=_(e);return new class extends u{compile(o){return{out:o.get(a(o)?e:t)}}}}const T=I(c(y.projectionMatrix)),q=I(c(y.viewMatrix)),A=h({uv:$,depth:s,inverseProjection:u,inverseView:u},k,({uv:e,depth:t,inverseProjection:o,inverseView:r})=>{const n=new G(t),s=new B(e),c=N(s.x,s.y,n,1),i=o.multiplyVec(c),p=i.xyz.divideScalar(b(i.w));return r.multiplyVec(N(p,1)).xyz});export function sampleSceneWorldPosition(e=screenUV){return A({uv:e,depth:E(e),inverseProjection:T,inverseView:q})}export function depthWorldPositionFromInverseViewProjection(e,t=screenUV,o=E(t)){const r=new G(o),n=new B(t),s=e.multiplyVec(N(n.x,n.y,r,1));return s.xyz.divideScalar(b(s.w))}class B extends ${constructor(e){super(),this.uv=e}compile(e){const t=e.get(this.uv);return{out:p(e)?`((${t}) * vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0))`:`((${t}) * 2.0 - 1.0)`}}}class G extends s{constructor(e){super(),this.depth=e}compile(e){const t=e.get(this.depth);return{out:p(e)?t:`((${t}) * 2.0 - 1.0)`}}}class H extends ${constructor(e){super(),this.ndc=e}compile(e){const t=e.get(this.ndc);return{out:p(e)?`((${t}) * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5))`:`((${t}) * 0.5 + 0.5)`}}}class J extends s{constructor(e){super(),this.ndcDepth=e}compile(e){const t=e.get(this.ndcDepth);return{out:p(e)?t:`((${t}) * 0.5 + 0.5)`}}}export function projectedSceneUV(e){return new H(e)}export function projectedSceneDepth(e){return new J(e)}export const depthWorldPosition=sampleSceneWorldPosition(screenUV);const K=r(1).divide(m(resolution.x,r(1))),O=r(1).divide(m(resolution.y,r(1))),Q=sampleSceneWorldPosition(screenUV.add(P(K,r(0)))),R=sampleSceneWorldPosition(screenUV.add(P(r(0),new class extends s{constructor(e){super(),this.step=e}compile(e){const t=e.get(this.step);return{out:p(e)?`-(${t})`:t}}}(O))));export const depthNormal=(X=t(Q.subtract(depthWorldPosition),R.subtract(depthWorldPosition)),X.multiplyScalar(i(m(o(X,X),W))));var X;export const sceneNormalSampler=g(sceneNormalUniformName,D(128,128,255));export const sceneNormal=U(sceneNormalSampler.sample(screenUV).rgb);/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from 'three-shader-graph';
|
|
2
|
-
export { linearEyeDepth, fragmentLinearEyeDepth, depthNormal, depthSampler, depthWorldPosition, projectedSceneDepth, projectedSceneUV, resolution, sceneNormal, screenUV } from './depth.js';
|
|
2
|
+
export { linearEyeDepth, fragmentLinearEyeDepth, depthNormal, depthSampler, depthWorldPosition, sampleSceneWorldPosition, projectedSceneDepth, projectedSceneUV, resolution, sceneNormal, screenUV } from './depth.js';
|
|
3
3
|
export { timeUniforms } from './time.js';
|
|
4
4
|
export { particleUniforms } from './particle.js';
|
|
5
5
|
export * from './layers.js';
|
|
@@ -17,5 +17,6 @@ export * from './dither.js';
|
|
|
17
17
|
export * from './gradient-sample.js';
|
|
18
18
|
export * from './material-flags.js';
|
|
19
19
|
export * from './water.js';
|
|
20
|
+
export * from './surface-reflection.js';
|
|
20
21
|
export * from './wgsl-node.js';
|
|
21
22
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export*from"three-shader-graph";export{linearEyeDepth,fragmentLinearEyeDepth,depthNormal,depthSampler,depthWorldPosition,projectedSceneDepth,projectedSceneUV,resolution,sceneNormal,screenUV}from"./depth.js";export{timeUniforms}from"./time.js";export{particleUniforms}from"./particle.js";export*from"./layers.js";export*from"./landscape.js";export*from"./shapes.js";export*from"./voronoi.js";export*from"./effects.js";export*from"./math.js";export*from"./bulge.js";export*from"./texture-sequence.js";export*from"./decal.js";export*from"./scene-sample.js";export*from"./curve-sample.js";export*from"./dither.js";export*from"./gradient-sample.js";export*from"./material-flags.js";export*from"./water.js";export*from"./wgsl-node.js";/*
|
|
1
|
+
export*from"three-shader-graph";export{linearEyeDepth,fragmentLinearEyeDepth,depthNormal,depthSampler,depthWorldPosition,sampleSceneWorldPosition,projectedSceneDepth,projectedSceneUV,resolution,sceneNormal,screenUV}from"./depth.js";export{timeUniforms}from"./time.js";export{particleUniforms}from"./particle.js";export*from"./layers.js";export*from"./landscape.js";export*from"./shapes.js";export*from"./voronoi.js";export*from"./effects.js";export*from"./math.js";export*from"./bulge.js";export*from"./texture-sequence.js";export*from"./decal.js";export*from"./scene-sample.js";export*from"./curve-sample.js";export*from"./dither.js";export*from"./gradient-sample.js";export*from"./material-flags.js";export*from"./water.js";export*from"./surface-reflection.js";export*from"./wgsl-node.js";/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Vec3Node, Vec4Node } from 'three-shader-graph';
|
|
2
|
+
/**
|
|
3
|
+
* WebGPU reflection for late/refractive surfaces, using the opaque scene snapshot.
|
|
4
|
+
* Arguments are in view space. RGB is reflected radiance; A is hit confidence
|
|
5
|
+
* (the caller applies Fresnel). WebGL returns no hit, allowing an IBL fallback.
|
|
6
|
+
* Unlike fullscreen SSR this does not require the receiver in the opaque G-buffer.
|
|
7
|
+
*/
|
|
8
|
+
export declare function surfaceScreenSpaceReflection(position: Vec3Node, normal: Vec3Node): Vec4Node;
|
|
9
|
+
//# sourceMappingURL=surface-reflection.d.ts.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{isWgslCompiler as e,Vec4Node as t,uniforms as n}from"three-shader-graph";import{depthSampler as i}from"./depth.js";import{sceneColorSampler as r}from"./scene-sample.js";export function surfaceScreenSpaceReflection(e,t){return new o(e,t)}class o extends t{constructor(e,t){super(),this.position=e,this.normal=t}compile(t){if(!e(t))return{out:"vec4(0.0)"};const o=t.get(i),c=t.get(r),l=t.get(n.projectionMatrix),a=t.get(this.position),p=t.get(this.normal),s=`surfaceReflection_${t.variable()}`;return t.addWgslDefinition(`\nfn ${s}(position: vec3<f32>, normal: vec3<f32>, projection: mat4x4<f32>) -> vec4<f32> {\n let direction = reflect(normalize(position), normalize(normal));\n let origin = position + normal * 0.04;\n let size = vec2<i32>(textureDimensions(${o}Texture));\n var previousDistance = 0.0;\n var previousDelta = -1.0;\n // Quadratic spacing concentrates work near the receiver. Fixed budget, no\n // screen-sized loop: 32 depth taps plus five refinement taps on a crossing.\n for (var i = 1; i <= 32; i += 1) {\n let t = f32(i) / 32.0;\n let distance = 60.0 * t * t;\n let point = origin + direction * distance;\n let clip = projection * vec4<f32>(point, 1.0);\n if (clip.w <= 0.0 || clip.z <= 0.0) { break; }\n let uv = clip.xy / clip.w * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);\n if (any(uv <= vec2<f32>(0.0)) || any(uv >= vec2<f32>(1.0))) { break; }\n let pixel = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - vec2<i32>(1));\n let d = textureLoad(${o}Texture, pixel, 0).r;\n let sceneZ = (projection[3].z - d * projection[3].w) / (d * projection[2].w - projection[2].z);\n let delta = sceneZ - point.z;\n if (d < 1.0 && delta >= 0.0 && previousDelta < 0.0) {\n var low = previousDistance;\n var high = distance;\n var hitUv = uv;\n var hitDelta = delta;\n for (var j = 0; j < 5; j += 1) {\n let mid = (low + high) * 0.5;\n let p = origin + direction * mid;\n let projected = projection * vec4<f32>(p, 1.0);\n let sampleUv = projected.xy / projected.w * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);\n let coord = clamp(vec2<i32>(sampleUv * vec2<f32>(size)), vec2<i32>(0), size - vec2<i32>(1));\n let sampleDepth = textureLoad(${o}Texture, coord, 0).r;\n let z = (projection[3].z - sampleDepth * projection[3].w) / (sampleDepth * projection[2].w - projection[2].z);\n if (z - p.z >= 0.0 && sampleDepth < 1.0) {\n high = mid; hitUv = sampleUv; hitDelta = z - p.z;\n } else { low = mid; }\n }\n // Reject silhouette crossings that never converge onto a real surface.\n if (hitDelta < 0.25) {\n let edge = min(min(hitUv.x, hitUv.y), min(1.0 - hitUv.x, 1.0 - hitUv.y));\n let confidence = smoothstep(0.0, 0.08, edge) * (1.0 - high / 60.0);\n let reflected = textureSampleLevel(${c}Texture, ${c}Sampler, hitUv, 0.0).rgb;\n return vec4<f32>(reflected, confidence);\n }\n return vec4<f32>(0.0);\n }\n previousDistance = distance;\n previousDelta = delta;\n }\n return vec4<f32>(0.0);\n}`),{out:`${s}(${a}, ${p}, ${l})`}}}/*
|
|
2
|
+
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
|
+
* See the LICENSE.md file for details.
|
|
4
|
+
*/
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { type Vec2Node } from 'three-shader-graph';
|
|
1
2
|
export declare const waterVolumeWorldToLocalUniformName = "hology_water_world_to_local";
|
|
2
3
|
export declare const waterVolumeCameraPositionUniformName = "hology_water_camera_position";
|
|
3
4
|
export declare const waterVolumeEnabledUniformName = "hology_water_volume_enabled";
|
|
4
5
|
export declare const waterVolumeInverseViewProjectionUniformName = "hology_water_inverse_view_projection";
|
|
5
|
-
/**
|
|
6
|
+
/** Samples water distance and coverage along the scene ray at the supplied UV. */
|
|
7
|
+
export declare function sampleWaterVolume(uv?: Vec2Node): {
|
|
8
|
+
distance: import("three-shader-graph").FloatNode;
|
|
9
|
+
mask: import("three-shader-graph").FloatNode;
|
|
10
|
+
};
|
|
6
11
|
export declare const waterVolumeDistance: import("three-shader-graph").FloatNode;
|
|
7
|
-
/** Binary per-pixel coverage of the active box water volume. */
|
|
8
12
|
export declare const waterVolumeMask: import("three-shader-graph").FloatNode;
|
|
9
13
|
//# sourceMappingURL=water-volume.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{Matrix4 as e,Vector3 as o}from"three";import{abs as t,float as r,length as a,max as l,min as
|
|
1
|
+
import{Matrix4 as e,Vector3 as o}from"three";import{abs as t,float as r,length as a,max as l,min as m,saturate as i,step as n,uniformFloat as c,uniformMat4 as s,uniformVec3 as p,vec3 as _,vec4 as y}from"three-shader-graph";import{screenUV as u,depthWorldPositionFromInverseViewProjection as w}from"./depth.js";export const waterVolumeWorldToLocalUniformName="hology_water_world_to_local";export const waterVolumeCameraPositionUniformName="hology_water_camera_position";export const waterVolumeEnabledUniformName="hology_water_volume_enabled";export const waterVolumeInverseViewProjectionUniformName="hology_water_inverse_view_projection";const d=s("hology_water_world_to_local",new e),x=p("hology_water_camera_position",new o),h=c("hology_water_volume_enabled",0),V=s("hology_water_inverse_view_projection",new e);export function sampleWaterVolume(e=u){const o=w(V,e),c=w(V,e,r(0)),s=d.multiplyVec(y(x,1)).xyz,p=d.multiplyVec(y(c,1)).xyz,g=d.multiplyVec(y(o,1)).xyz.subtract(s),v=r(1e-6),z=e=>e.add(n(t(e),v).multiply(v)),f=_(z(g.x),z(g.y),z(g.z)),b=s.multiplyScalar(-1).subtractScalar(.5).divide(f),j=s.multiplyScalar(-1).addScalar(.5).divide(f),N=l(l(m(b.x,j.x),m(b.y,j.y)),m(b.z,j.z)),S=m(m(l(b.x,j.x),l(b.y,j.y)),l(b.z,j.z)),U=l(N,r(0)),k=m(S,r(1)),W=l(h,r(0)),P=l(k.subtract(U),r(0)).multiply(W),C=a(o.subtract(x)).multiply(P),D=n(l(l(t(p.x),t(p.y)),t(p.z)),r(.5)),E=l(i(C.multiply(4)).multiply(D),n(h,r(-.5)));return{distance:C,mask:E}}const g=sampleWaterVolume();export const waterVolumeDistance=g.distance;export const waterVolumeMask=g.mask;/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{describe as e,expect as t,it as a,vi as r}from"vitest";import{Texture as
|
|
1
|
+
import{describe as e,expect as t,it as a,vi as r}from"vitest";import{DataTexture as o,SRGBColorSpace as n,Texture as s}from"three";import{AssetResourceLoader as i}from"../scene/asset-resource-loader.js";function l(e=1){const t=new ArrayBuffer(120);new Uint8Array(t).set([171,75,84,88,32,50,48,187,13,10,26,10]);const a=new DataView(t);for(const[t,r]of[[12,146],[16,1],[20,4],[24,4],[32,e],[36,1],[40,1]])a.setUint32(t,r,!0);return a.setBigUint64(80,104n,!0),a.setBigUint64(88,16n,!0),a.setBigUint64(96,16n,!0),t}function c(e,t){return{id:e,name:e,type:"texture",fileKey:`${e}.ktx2`,fileFormat:"ktx2",texture:{textureArrayFileKey:"shared-array.ktx2",textureArrayLayer:t}}}r.mock("../scene/materializer.js",()=>({applyMaterial:r.fn(),getConvertedFbxToGlbAssignedMaterialTextureFlipY:r.fn()})),r.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&r.fn()),e[t]),set:(e,t,a)=>(e[t]=a,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e}),"undefined"==typeof AudioBuffer&&Object.defineProperty(globalThis,"AudioBuffer",{configurable:!0,value:class{}})}),e("AssetResourceLoader texture arrays",()=>{a("keeps raw image textures and applies asset settings without wrapping them as bitmaps",async()=>{const e=new i;e.setDataDir("data"),e.getUri=e=>e;const a=new Uint8Array([255,0,0,0,0,255,0,128]),s=new o(a,1,2),l=new o(a,1,2),c=r.fn().mockResolvedValueOnce(s).mockResolvedValueOnce(l);e.setImageTextureDecoder(c);const u={id:"raw",name:"raw",type:"texture",fileKey:"raw.png",fileFormat:"png"};t(await e.getTexture(u)).toBe(s),t(s.image.data).toBe(a),t(s.flipY).toBe(!1),t(s.colorSpace).toBe(n),t(await e.getTexture(u)).toBe(s),t(c).toHaveBeenCalledExactlyOnceWith("raw.png",!0),u.texture={flipY:!1},t(await e.getTexture(u)).toBe(l),t(l.flipY).toBe(!1),t(c).toHaveBeenNthCalledWith(2,"raw.png",!1),t(c).toHaveBeenCalledTimes(2)}),a("uses native BC7 with the custom WebGPU device and deduplicates array requests",async()=>{const e=new i,a=e,o=r.spyOn(a.ktx2Loader,"detectSupport").mockReturnThis();e.initKtx2ForWebGpu({features:new Set(["texture-compression-bc"])}),a.getUri=e=>e;const n=r.spyOn(a.nativeTextureLoader,"loadAsync").mockResolvedValue(l()),s=r.spyOn(a.ktx2Loader,"loadAsync"),u=c("a",0);u.texture.nativeArrayFileKeys={bc7:"native-array.ktx2",astc:"mobile.ktx2"};const[d,x]=await Promise.all([e.getTextureArray(u),e.getTextureArray(u)]);t(d.texture).toBe(x.texture),t(d.texture.isCompressedArrayTexture).toBe(!0),t(n).toHaveBeenCalledExactlyOnceWith("native-array.ktx2"),t(s).not.toHaveBeenCalled(),t(o).toHaveBeenCalled();const y=o.mock.calls[0][0];t(y.hasFeature("texture-compression-bc")).toBe(!0),t(y.hasFeature("texture-compression-bptc")).toBe(!0),t(y.hasFeature("texture-compression-astc")).toBe(!1),r.restoreAllMocks()}),a("loads standalone native textures and falls back when their payload is invalid",async()=>{const e=new i,a=e;a.getUri=e=>e,a.nativeBcSupported=!0,e.setDataDir("data");const o=r.spyOn(a.nativeTextureLoader,"loadAsync").mockResolvedValueOnce(l(0)).mockResolvedValueOnce(new ArrayBuffer(0)),n=new s,u=r.spyOn(a.ktx2Loader,"loadAsync").mockResolvedValue(n),d=r.spyOn(console,"warn").mockImplementation(()=>{}),x=c("a",0);x.texture.nativeFileKeys={bc7:"native.ktx2"},t((await e.getTexture(x)).isCompressedTexture).toBe(!0);const y={...x,id:"b",fileKey:"fallback.ktx2"};t(await e.getTexture(y)).toBe(n),t(o).toHaveBeenCalledTimes(2),t(u).toHaveBeenCalledExactlyOnceWith("fallback.ktx2"),t(d).toHaveBeenCalled(),r.restoreAllMocks()}),a("selects native formats from WebGL extensions",async()=>{const e=new i,a=e;r.spyOn(a.ktx2Loader,"detectSupport").mockReturnThis(),e.initKtx2({extensions:{has:e=>"WEBGL_compressed_texture_astc"===e}}),t(a.nativeBcSupported).toBe(!1),t(a.nativeAstcSupported).toBe(!0),r.restoreAllMocks()}),a("deduplicates concurrent loads by texture array file key",async()=>{const e=new i;let a;const o=new Promise(e=>{a=e}),n=r.fn(()=>o);e._getTextureLoader=()=>({loadAsync:n}),e.getUri=e=>e;const l=e.getTextureArray(c("texture-a",0)),u=e.getTextureArray(c("texture-b",1));await r.waitFor(()=>t(n).toHaveBeenCalledTimes(1));const d=new s;a(d);const[x,y]=await Promise.all([l,u]);t(x.texture).toBe(d),t(y.texture).toBe(d),t(x.layerIndex).toBe(0),t(y.layerIndex).toBe(1)}),a("allows a failed texture array load to be retried",async()=>{const e=new i,a=new s,o=r.fn().mockRejectedValueOnce(new Error("load failed")).mockResolvedValueOnce(a);e._getTextureLoader=()=>({loadAsync:o}),e.getUri=e=>e;const n=c("texture-a",0);await t(e.getTextureArray(n)).rejects.toThrow("load failed"),await t(e.getTextureArray(n)).resolves.toEqual({texture:a,layerIndex:0}),t(o).toHaveBeenCalledTimes(2)})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{afterAll as e,beforeAll as t,describe as o,expect as n,test as
|
|
1
|
+
import{afterAll as e,beforeAll as t,describe as o,expect as n,test as a,vi as s}from"vitest";import{Vector3 as i}from"three";import{CharacterMovementMode as r}from"../gameplay/actors/builtin/components/character/modes.js";import{MOVE_FLAG_JUMP as l}from"../gameplay/actors/builtin/components/character/net-character-movement-protocol.js";let c,m;function d(e,t,o=r.walking){const n=new i,a=[],l={getActorComputedMovement:s.fn((e,t,o)=>(a.push(o.clone()),n.copy(o).multiplyScalar(.5))),setNextKinematicPosition:s.fn(),updateActorTransform:s.fn(),flushModifiedBodyPositionsToColliders:s.fn()};return{component:{actor:{position:new i},physicsSystem:l,state:{grounded:e,mode:o,position:new i},rayTestResult:{distance:1/0},suppressUncrouchLift:!1,getCollisionGroup:()=>null,getEffectiveGravity:()=>-10,cc:{offset:()=>.01,maxSlopeClimbAngle:()=>Math.PI/4,computedCollision:s.fn((e,o)=>null==t?null:(o.normal1=t,o))}},requests:a,physicsSystem:l}}function p(){const e=Object.create(m);return Object.assign(e,{swimmingEnabled:!0,maxSpeedSwimming:3,swimmingImmersion:.7,jumpVelocity:7,swimmingSurfaceResponse:6,swimmingMaxVerticalSpeed:4,swimmingDepthHysteresis:.1,colliderHeight:1.2,colliderRadius:.25,groundAccelerationRate:4,normalizedDirection:!0,waterProbe:new i,waterVolumes:[{getWaterSurfaceHeight:e=>e.y<=0&&e.y>=-10&&Math.abs(e.x)<5?0:null}],state:{position:new i(0,-2,0),velocity:new i(0,-20,0),mode:r.falling,grounded:!1,currentSpeed:8,fallGraceTime:1}}),e}e(()=>s.restoreAllMocks()),t(async()=>{"undefined"!=typeof HTMLCanvasElement&&s.spyOn(HTMLCanvasElement.prototype,"getContext").mockImplementation(()=>({fillStyle:"",fillRect(){},getImageData:()=>({data:new Uint8ClampedArray(4)})}));const{CharacterMovementComponent:e}=await import("../gameplay/actors/builtin/components/character/character-movement.js");c=e.prototype.applyMovement,m=e.prototype},3e4),o("character movement solve reuse",()=>{a.each([r.walking,r.sliding])("solves ordinary grounded movement once in mode %s",e=>{const{component:t,requests:o,physicsSystem:a}=d(!0,new i(0,1,0),e);c.call(t,new i(2,-1,0),.016,!1),n(o).toHaveLength(1),n(t.state.position.toArray()).toEqual([.016,-.008,0]),n(a.setNextKinematicPosition).toHaveBeenCalledWith(t.actor,t.state.position)}),a("solves again when steep-slope handling changes the requested translation",()=>{const{component:e,requests:t}=d(!0,new i(1,0,0));c.call(e,new i(2,0,0),.016,!1),n(t).toHaveLength(2),n(t[0].y).toBe(0),n(t[1].y).toBe(-.08),n(e.state.position.toArray()).toEqual([.016,-.04,0])}),a("reuses steep-slope results when the requested translation already matches",()=>{const{component:e,requests:t}=d(!0,new i(1,0,0));c.call(e,new i(2,-.08,0),1,!1),n(t).toHaveLength(1),n(e.state.position.toArray()).toEqual([1,-.04,0])}),a("retains the first movement result when no collision normal is available",()=>{const{component:e,requests:t}=d(!0,null);c.call(e,new i(2,0,0),.016,!1),n(t).toHaveLength(1),n(e.state.position.x).toBe(.016)}),a("does not reuse another character’s steep collision normal after a miss",()=>{const e=d(!0,new i(1,0,0));c.call(e.component,new i(2,0,0),.016,!1);const t=d(!0,null);c.call(t.component,new i(2,0,0),.016,!1),n(t.requests).toHaveLength(1),n(t.component.state.position.y).toBe(0)}),a.each([!1,!0])("ground probes respect characterCollision=%s",e=>{const t=s.fn(),o=Object.create(m);Object.assign(o,{characterCollision:e,cc:{offset:()=>0,snapToGroundDistance:()=>.1},physicsSystem:{rayTest:t},groundRayTestOptions:{},rayTestResult:{}}),o.checkGrounded(new i),n(t.mock.calls[0][3].collisionFilter).toBe(e?null:131070)}),a("keeps airborne solves and immediate physics synchronization",()=>{const{component:e,requests:t,physicsSystem:o}=d(!1,null);c.call(e,new i(2,-1,0),.016,!0),n(t).toHaveLength(1),n(e.cc.computedCollision).not.toHaveBeenCalled(),n(e.actor.position.toArray()).toEqual(e.state.position.toArray()),n(o.updateActorTransform).toHaveBeenCalledWith(e.actor),n(o.flushModifiedBodyPositionsToColliders).toHaveBeenCalledOnce()}),a("does not solve stationary grounded movement",()=>{const{component:e,requests:t}=d(!0,null);c.call(e,new i,.016,!1),n(t).toHaveLength(0),n(e.state.position.toArray()).toEqual([0,0,0])}),a("disables ground snapping during swimming and restores the controller setting",()=>{const{component:e,requests:t}=d(!1,null,r.swimming);Object.assign(e.cc,{snapToGroundDistance:()=>.1,disableSnapToGround:s.fn(),enableSnapToGround:s.fn()}),c.call(e,new i(2,0,0),.016,!1),n(t).toHaveLength(1),n(e.cc.disableSnapToGround).toHaveBeenCalledOnce(),n(e.cc.enableSnapToGround).toHaveBeenCalledWith(.1)})}),o("automatic surface swimming",()=>{a("reports wading contact without changing movement mode, and rejects dry or submerged bodies",()=>{const e=p();e.state.mode=r.walking,e.state.position.y=-.3,n(e.waterContactSurfaceHeight).toBe(0),n(e.state.mode).toBe(r.walking),e.state.position.y=.1,n(e.waterContactSurfaceHeight).toBeNull(),e.state.position.y=-3,n(e.waterContactSurfaceHeight).toBeNull(),e.state.position.set(6,-.3,0),n(e.waterContactSurfaceHeight).toBeNull()}),a("retains the cached swimming surface for wake contact",()=>{const e=p();e.updateWaterMovementMode(),n(e.waterContactSurfaceHeight).toBe(e.swimmingWaterSurfaceHeight),n(e.waterContactSurfaceHeight).toBe(0)}),a("keeps shallow water walkable and enters deeper water from a fall",()=>{const e=p();e.state.mode=r.walking,e.state.position.y=-.5,e.updateWaterMovementMode(),n(e.state.mode).toBe(r.walking),e.state.position.y=-2,e.updateWaterMovementMode(),n(e.state.mode).toBe(r.swimming),n(e.state.velocity.y).toBe(0),n(e.state.currentSpeed).toBe(3),n(e.state.grounded).toBe(!1)}),a("keeps the mode stable at floating depth and exits at banks or when disabled",()=>{const e=p();e.updateWaterMovementMode(),e.state.position.y=-e.getSwimmingDepth(),e.updateWaterMovementMode(),n(e.state.mode).toBe(r.swimming),e.state.position.y+=.11,e.updateWaterMovementMode(),n(e.state.mode).toBe(r.falling),e.state.position.set(0,-2,0),e.updateWaterMovementMode(),e.state.position.x=6,e.updateWaterMovementMode(),n(e.state.mode).toBe(r.falling),e.state.position.x=0,e.updateWaterMovementMode(),e.swimmingEnabled=!1,e.updateWaterMovementMode(),n(e.state.mode).toBe(r.falling)}),a("selects the highest overlapping surface and leaves custom modes alone",()=>{const e=p();e.waterVolumes.push({getWaterSurfaceHeight:()=>2}),e.updateWaterMovementMode(),n(e.swimmingSurfaceHeight).toBe(2),e.state.mode=r.flying,e.updateWaterMovementMode(),n(e.state.mode).toBe(r.flying)}),a("prediction and replay reach the same surface position without gravity",()=>{const e=()=>{const e=p(),t={state:e.state,move:{inputX:0,inputY:1,flags:0},deltaTime:1/60,rotatedDirection:new i(0,0,1),outVelocity:new i};for(let o=0;o<180;o++)e.updateWaterMovementMode(),e.updateSwimmingMode(t),e.state.position.addScaledVector(t.outVelocity,t.deltaTime),e.updateModeAfterMove(t.outVelocity,t.deltaTime),n(e.state.position.y).toBeLessThanOrEqual(-e.getSwimmingDepth()),n(e.state.mode).toBe(r.swimming);return n(e.state.position.y).toBeCloseTo(-e.getSwimmingDepth(),5),n(e.state.currentSpeed).toBeCloseTo(3),t.move.inputY=0,t.rotatedDirection.set(0,0,0),e.updateSwimmingMode(t),n(e.state.currentSpeed).toBe(0),n(t.outVelocity.x).toBe(0),n(t.outVelocity.z).toBe(0),e.state.position};n(e().toArray()).toEqual(e().toArray())}),a.each([0,1])("jumps from water with input magnitude %s and resumes swimming on descent",e=>{const t=p();t.updateWaterMovementMode(),t.state.position.y=-t.getSwimmingDepth();const o={state:t.state,move:{inputX:0,inputY:e,flags:l},deltaTime:1/60,rotatedDirection:new i(0,0,e),outVelocity:new i,rootMotionActive:!1};t.updateSwimmingMode(o),n(t.state.mode).toBe(r.falling),n(t.state.velocity.y).toBe(t.jumpVelocity),n(o.outVelocity.toArray()).toEqual(t.state.velocity.toArray()),n(o.outVelocity.z).toBeGreaterThanOrEqual(0),n(t.state.grounded).toBe(!1),t.updateWaterMovementMode(),n(t.state.mode).toBe(r.falling),n(t.state.velocity.y).toBe(t.jumpVelocity),t.state.position.y-=.2,t.state.velocity.y=-1,t.updateWaterMovementMode(),n(t.state.mode).toBe(r.swimming),n(t.state.velocity.y).toBe(0)}),a("does not cancel active root motion with a swimming jump",()=>{const e=p();e.updateWaterMovementMode(),e.updateSwimmingMode({state:e.state,move:{inputX:0,inputY:0,flags:l},deltaTime:1/60,rotatedDirection:new i,outVelocity:new i,rootMotionActive:!0}),n(e.state.mode).toBe(r.swimming)})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{expect as e,test as n}from"vitest";import{BoxGeometry as
|
|
1
|
+
import{expect as e,test as n}from"vitest";import{BoxGeometry as o,Euler as t,Group as s,Mesh as i,MeshBasicMaterial as a,Quaternion as r,Vector3 as c}from"three";import{importCollisionShapes as l}from"../scene/collision/collision-shape-import.js";import{BoxCollisionShape as m,CollisionShapeSource as d,toInstancedCollisionShape as p}from"../scene/collision/collision-shape.js";import{calculateOBB as u}from"../utils/obb-utils.js";function w(n,o,t=1e-5){e(n.distanceTo(o)).toBeLessThan(t)}function f(n,o,t=1e-5){e(n.angleTo(o)).toBeLessThan(t)}n("mesh import indexes collision-only positions once and preserves mesh transforms",()=>{const n=new s,t=new o(2,2,2).toNonIndexed(),a=new i(t);a.position.set(3,0,0),a.scale.set(2,1,1),n.add(a),n.updateMatrixWorld(!0);const[r]=l(n,{mesh:{collisions:{shapeType:"mesh"}}});e(r.geometry.index?.count).toBe(36),e(r.geometry.getAttribute("position").count).toBe(8),e(Object.keys(r.geometry.attributes)).toEqual(["position"]),r.geometry.computeBoundingBox(),w(r.geometry.boundingBox.min,new c(1,-1,-1)),w(r.geometry.boundingBox.max,new c(5,1,1)),e(t.index).toBeNull(),e(t.getAttribute("normal")).toBeDefined()}),n("box import keeps a stable local-axis box and composes only the mesh transform",()=>{const n=new s,t=new o(4,1,2);t.rotateX(.2),t.rotateY(.65),t.translate(.4,-.15,.3);const p=new i(t,new a);p.position.set(3,-2,1),p.rotation.set(.35,1.1,-.45),p.scale.set(1.5,1,.75),n.add(p),n.updateMatrixWorld(!0);t.computeBoundingBox();const u=t.boundingBox,h=u.getCenter(new c),x=u.getSize(new c),y=x.clone().multiply(p.scale),[b]=l(n,{mesh:{collisions:{shapeType:"box"}}}),B=b,g=h.clone().multiply(p.scale).applyQuaternion(p.quaternion).add(p.position);e(B).toBeInstanceOf(m),e(B.source).toBe(d.rendered),w(B.dimensions,y),w(B.offset,g),f((new r).setFromEuler(B.rotation),p.quaternion),w(B.instancedDimensions,x),w(B.instancedOffset,h),f((new r).setFromEuler(B.instancedRotation),new r)}),n("box import bakes scaled parent transforms into dimensions",()=>{const e=new s,n=new s,t=new i(new o(100,50,2),new a);n.scale.set(.01,.02,.5),n.add(t),e.add(n),e.updateMatrixWorld(!0);const[r]=l(e,{mesh:{collisions:{shapeType:"box"}}}),m=r;w(m.dimensions,new c(1,1,1)),w(m.instancedDimensions,new c(100,50,2))}),n("obb import composes the mesh transform with the local OBB transform",()=>{const n=new s,t=new o(4,1,2);t.rotateX(.2),t.rotateY(.65),t.translate(.4,-.15,.3);const c=new i(t,new a);c.position.set(3,-2,1),c.rotation.set(.35,1.1,-.45),c.scale.set(1.5,1,.75),n.add(c),n.updateMatrixWorld(!0);const[p]=l(n,{mesh:{collisions:{shapeType:"obb"}}}),h=p,x=u(t),y=x.center.clone().multiply(c.scale).applyQuaternion(c.quaternion).add(c.position),b=c.quaternion.clone().multiply((new r).setFromEuler(x.rotation));e(h).toBeInstanceOf(m),e(h.source).toBe(d.rendered),w(h.offset,y),f((new r).setFromEuler(h.rotation),b),w(h.instancedOffset,x.center),f((new r).setFromEuler(h.instancedRotation),(new r).setFromEuler(x.rotation))}),n("convex import keeps a rotated child mesh transform exactly once",()=>{const e=new s,n=new s,t=new i(new o(1,2,3),new a);t.rotation.set(-Math.PI/2,0,0),n.add(t),e.add(n),e.updateMatrixWorld(!0);const[c]=l(e,{mesh:{collisions:{shapeType:"convex"}}});f((new r).setFromEuler(c.rotation),t.quaternion)}),n("instanced collision shapes use the mesh-local transform without mutating the cached shape",()=>{const n=new m(new c(1,2,3));n.source=d.rendered,n.offset.set(10,11,12),n.rotation.set(.4,.5,.6),n.instancedOffset=new c(1,2,3),n.instancedRotation=new t(.1,.2,.3),n.instancedDimensions=new c(.25,.5,.75);const o=p(n);e(o).not.toBe(n),w(o.dimensions,n.instancedDimensions),w(o.offset,n.instancedOffset),f((new r).setFromEuler(o.rotation),(new r).setFromEuler(n.instancedRotation)),w(n.offset,new c(10,11,12)),w(n.dimensions,new c(1,2,3)),f((new r).setFromEuler(n.rotation),(new r).setFromEuler(new t(.4,.5,.6)))});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import"reflect-metadata";import{afterEach as e,expect as t,test as o,vi as n}from"vitest";n.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&n.fn()),e[t]),set:(e,t,o)=>(e[t]=o,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),n.mock("../gameplay/services/render.js",()=>({ViewController:class{}})),n.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{},RayTestResult:class{},ShapeCastResult:class{}})),n.mock("../gameplay/services/world.js",()=>({World:class{}})),n.mock("@hology/nebula",()=>({ease:{easeInOutCubic:e=>e}}));import{Subject as a}from"rxjs";import{Container as s}from"typedi";import{Object3D as r,PerspectiveCamera as i,Ray as c,Scene as l,Vector3 as p}from"three";import{BaseActor as m}from"../gameplay/actors/actor.js";import{FirstPersonCameraComponent as d}from"../gameplay/actors/camera/first-person-camera-component.js";import{ThirdPersonCameraComponent as u}from"../gameplay/actors/camera/third-person-camera-component.js";import{PhysicsSystem as h}from"../gameplay/services/physics/physics-system.js";import{ViewController as y}from"../gameplay/services/render.js";import{World as f}from"../gameplay/services/world.js";function w(e=new C){const t=v(),o=new d;return o.actor=e,{camera:o,actor:e,element:t}}function v(){const e=document.createElement("div"),t=document.createElement("canvas");return e.appendChild(t),Object.defineProperty(e,"clientWidth",{configurable:!0,value:1280}),Object.defineProperty(e,"clientHeight",{configurable:!0,value:720}),s.set(y,{htmlElement:e,setCamera:n.fn(),onUpdate:()=>new a,onLateUpdate:()=>new a}),s.set(f,{scene:new l}),s.set(h,{sphereCast:n.fn()}),e}function B(){Object.defineProperty(document.body,"requestPointerLock",{configurable:!0,value:n.fn()}),Object.defineProperty(document,"pointerLockElement",{configurable:!0,value:null})}function g(e,t){return e.mock.calls.filter(e=>e[0]===t).length}e(()=>{n.restoreAllMocks(),s.reset()}),o("first-person camera clamps pitch input",()=>{const{camera:e}=w();e.rotationInput.rotateX(Math.PI),t(e.rotationInput.rotation.x).toBeCloseTo(Math.PI/2-.01),e.rotationInput.rotateX(2*-Math.PI),t(e.rotationInput.rotation.x).toBeCloseTo(-Math.PI/2+.01)}),o("first-person camera follows actor eye offset",async()=>{const e=new C;e.position.set(1,2,3),e.rotation.y=Math.PI/2,e.object.updateMatrixWorld(!0);const{camera:o}=w(e);o.eyeHeight=1.5,o.offsetZ=.25,o.autoActivate=!1,await o.onInit(),o.updateCameraTransform(),t(o.camera.position.x).toBeCloseTo(1.25),t(o.camera.position.y).toBeCloseTo(3.5),t(o.camera.position.z).toBeCloseTo(3)}),o("first-person aim helpers write to caller-provided objects",async()=>{const e=new C;e.position.set(0,1,0);const{camera:o}=w(e);o.autoActivate=!1,await o.onInit();const n=new p,a=new p,s=new c;t(o.getAimOrigin(n)).toBe(n),t(o.getAimDirection(a)).toBe(a),t(o.getAimRay(s)).toBe(s),t(s.origin).toEqual(n),t(s.direction).toEqual(a),t(a.x).toBeCloseTo(0),t(a.y).toBeCloseTo(0),t(a.z).toBeCloseTo(1),t(a.length()).toBeCloseTo(1)}),o("first-person camera pitch input tilts aim downward when positive",async()=>{const{camera:e}=w();e.autoActivate=!1,await e.onInit(),e.rotationInput.rotateX(.25),e.updateCameraTransform();const o=e.getAimDirection(new p);t(o.y).toBeLessThan(0),t(o.z).toBeGreaterThan(0),t(o.length()).toBeCloseTo(1)}),o("first-person hidden objects restore their previous visibility",()=>{const{camera:e}=w(),o=new r,n=new r;n.visible=!1,e.hideObjects(o,n),t(o.visible).toBe(!1),t(n.visible).toBe(!1),e.restoreHiddenObjects(),t(o.visible).toBe(!0),t(n.visible).toBe(!1)}),o("first-person activation is idempotent",async()=>{const{camera:e,element:o}=w();e.autoActivate=!1,await e.onInit(),B();const a=n.spyOn(o,"addEventListener"),s=n.spyOn(o,"removeEventListener"),r=n.spyOn(document,"addEventListener"),i=n.spyOn(document,"removeEventListener");e.activate(),e.activate(),t(g(a,"pointerdown")).toBe(1),t(g(a,"keydown")).toBe(1),t(g(r,"pointerlockchange")).toBe(1),e.deactivate(),e.deactivate(),t(g(s,"pointerdown")).toBe(1),t(g(s,"keydown")).toBe(1),t(g(i,"pointerlockchange")).toBe(1)}),o("third-person activation remains idempotent",async()=>{const e=new C,o=v(),a=new u;a.actor=e,a.autoActivate=!1,await a.onInit(),B();const s=n.spyOn(o,"addEventListener"),r=n.spyOn(o,"removeEventListener"),i=n.spyOn(document,"addEventListener"),c=n.spyOn(document,"removeEventListener");a.activate(),a.activate(),t(g(s,"pointerdown")).toBe(1),t(g(s,"keydown")).toBe(1),t(g(i,"pointerlockchange")).toBe(1),a.deactivate(),a.deactivate(),t(g(r,"pointerdown")).toBe(1),t(g(r,"keydown")).toBe(1),t(g(c,"pointerlockchange")).toBe(1)}),o("third-person camera hides the cursor only after pointer lock is active",async()=>{const e=new C,o=v();document.body.appendChild(o);const a=o.querySelector("canvas"),s=new u;s.actor=e,s.autoActivate=!1,await s.onInit(),B();const r=n.fn(()=>Promise.resolve());Object.defineProperty(a,"requestPointerLock",{configurable:!0,value:r});let i=null;Object.defineProperty(document,"pointerLockElement",{configurable:!0,get:()=>i}),s.activate(),s.hideCursor({ignoreRecentUnlock:!0}),t(r).toHaveBeenCalledWith({unadjustedMovement:!0}),t(s.isMouseLocked).toBe(!1),t(o.style.cursor).not.toBe("none"),i=a,document.dispatchEvent(new Event("pointerlockchange")),t(s.isMouseLocked).toBe(!0),t(o.style.cursor).toBe("none"),o.remove()}),o("third-person camera does not treat sustained fast movement as a teleport",async()=>{const e=new C;v();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!0,o.smoothSpeed=10,await o.onInit(),o.onLateUpdate(1/60);for(let t=0;t<12;t++)e.position.x+=.2,o.onLateUpdate(1/60);t(e.position.x-o.camera.position.x).toBeGreaterThan(.7)}),o("third-person camera still snaps after a target teleport",async()=>{const e=new C;v();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!0,o.teleportSnapDistance=5,await o.onInit(),o.onLateUpdate(1/60),e.position.x=10,o.onLateUpdate(1/60),t(o.camera.position.x).toBeCloseTo(10)}),o("third-person collision casts along the current frame camera arm",async()=>{const e=new C;v();const o=s.get(h);n.mocked(o.sphereCast).mockReturnValue({hasHit:!1});const a=new u;a.actor=e,a.autoActivate=!0,a.fixedBehind=!1,await a.onInit(),a.rotationInput.rotation.y=Math.PI/2,a.onLateUpdate(1/60);const[,,r,i]=n.mocked(o.sphereCast).mock.calls[0],c=Math.sqrt(a.distance**2+(a.offsetY-a.height)**2);t(r.x).toBeCloseTo(-a.distance/c),t(r.y).toBeCloseTo((a.offsetY-a.height)/c),t(i).toBeCloseTo(c)}),o("third-person collision preserves the safe camera arm and recovers smoothly",async()=>{const e=new C;v();const o=s.get(h);n.mocked(o.sphereCast).mockReturnValueOnce({hasHit:!0,distance:4}).mockReturnValue({hasHit:!1});const a=new u;a.actor=e,a.autoActivate=!0,a.fixedBehind=!1,await a.onInit();const r=new p(0,a.height,0);a.onLateUpdate(1/60),t(a.camera.position.distanceTo(r)).toBeCloseTo(3.95),a.onLateUpdate(.1);const i=a.camera.position.distanceTo(r);t(i).toBeGreaterThan(3.95),t(i).toBeLessThan(Math.sqrt(a.distance**2+(a.offsetY-a.height)**2))}),o("third-person camera blends temporary control over the gameplay pose",async()=>{const e=new C;v();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!1,await o.onInit(),o.onLateUpdate(1/60);const n=o.camera.position.clone(),a=new p(10,4,2),s=new p(1,2,3);o.setTemporaryControl(a,s,{blendInDuration:1}),o.onLateUpdate(.5),t(o.camera.position.x).toBeCloseTo((n.x+a.x)/2),t(o.camera.position.y).toBeCloseTo((n.y+a.y)/2),o.onLateUpdate(.5),t(o.camera.position).toEqual(a)}),o("third-person camera blends temporary control back to the current gameplay pose",async()=>{const e=new C;v();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!1,await o.onInit();const n=new p(10,4,2);o.setTemporaryControl(n,new p(1,2,3),{blendInDuration:0}),o.onLateUpdate(1/60),e.position.x=2,o.clearTemporaryControl(1),o.onLateUpdate(.5);const a=new p(2,o.offsetY,-o.distance);t(o.camera.position.x).toBeCloseTo((n.x+a.x)/2),t(o.camera.position.y).toBeCloseTo((n.y+a.y)/2)}),o("third-person camera applies collision to temporary control",async()=>{const e=new C;v();const o=s.get(h);n.mocked(o.sphereCast).mockReturnValue({hasHit:!0,distance:2});const a=new u;a.actor=e,a.autoActivate=!0,a.collision=!0,a.fixedBehind=!1,a.smoothCamera=!1,await a.onInit(),a.setTemporaryControl(new p(0,0,10),new p,{blendInDuration:0}),a.onLateUpdate(1/60),t(a.camera.position.z).toBeCloseTo(1.95)}),o("third-person camera pans toward a temporary look-at target without repositioning",async()=>{const e=new C;v();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!1,await o.onInit(),o.onLateUpdate(1/60);const n=o.camera.position.clone(),a=o.camera.quaternion.clone(),s=new p(0,2,-20),r=new i;r.position.copy(n),r.lookAt(s);const c=a.angleTo(r.quaternion);o.setTemporaryLookAt(s,{blendInDuration:1}),o.onLateUpdate(.5),t(o.camera.position).toEqual(n),t(a.angleTo(o.camera.quaternion)).toBeCloseTo(c/2)}),o("third-person camera blends a temporary field of view and restores it",async()=>{const e=new C;v();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,await o.onInit();const n=o.camera.fov;o.setTemporaryLookAt(new p(0,2,10),{blendInDuration:1,blendOutDuration:1,fieldOfView:35}),o.onLateUpdate(1),t(o.camera.fov).toBe(35),o.clearTemporaryControl(),o.onLateUpdate(.5),t(o.camera.fov).toBeCloseTo((35+n)/2),o.onLateUpdate(.5),t(o.camera.fov).toBe(n)});class C extends m{constructor(){super(),this.__isInitialised=!0}}/*
|
|
1
|
+
import"reflect-metadata";import{afterEach as e,expect as t,test as o,vi as a}from"vitest";a.hoisted(()=>{if("undefined"==typeof HTMLCanvasElement)return;const e=new Proxy({},{get:(e,t)=>(t in e||(e[t]=("string"!=typeof t||!t.startsWith("is"))&&a.fn()),e[t]),set:(e,t,o)=>(e[t]=o,!0)});Object.defineProperty(HTMLCanvasElement.prototype,"getContext",{configurable:!0,value:()=>e})}),a.mock("../gameplay/services/render.js",()=>({ViewController:class{}})),a.mock("../gameplay/services/physics/physics-system.js",()=>({PhysicsSystem:class{},RayTestResult:class{},ShapeCastResult:class{}})),a.mock("../gameplay/services/world.js",()=>({World:class{}})),a.mock("@hology/nebula",()=>({ease:{easeInOutCubic:e=>e}}));import{Subject as n}from"rxjs";import{Container as s}from"typedi";import{Object3D as i,PerspectiveCamera as r,Ray as c,Scene as l,Vector3 as p}from"three";import{BaseActor as m}from"../gameplay/actors/actor.js";import{FirstPersonCameraComponent as d}from"../gameplay/actors/camera/first-person-camera-component.js";import{ThirdPersonCameraComponent as u}from"../gameplay/actors/camera/third-person-camera-component.js";import{PhysicsSystem as h}from"../gameplay/services/physics/physics-system.js";import{ViewController as y}from"../gameplay/services/render.js";import{World as v}from"../gameplay/services/world.js";function w(e=new g){const t=f(),o=new d;return o.actor=e,{camera:o,actor:e,element:t}}function f(){const e=document.createElement("div"),t=document.createElement("canvas");return e.appendChild(t),Object.defineProperty(e,"clientWidth",{configurable:!0,value:1280}),Object.defineProperty(e,"clientHeight",{configurable:!0,value:720}),s.set(y,{htmlElement:e,setCamera:a.fn(),onUpdate:()=>new n,onLateUpdate:()=>new n}),s.set(v,{scene:new l}),s.set(h,{sphereCast:a.fn()}),e}function B(){Object.defineProperty(document.body,"requestPointerLock",{configurable:!0,value:a.fn()}),Object.defineProperty(document,"pointerLockElement",{configurable:!0,value:null})}function C(e,t){return e.mock.calls.filter(e=>e[0]===t).length}e(()=>{a.restoreAllMocks(),s.reset()}),o("first-person camera clamps pitch input",()=>{const{camera:e}=w();e.rotationInput.rotateX(Math.PI),t(e.rotationInput.rotation.x).toBeCloseTo(Math.PI/2-.01),e.rotationInput.rotateX(2*-Math.PI),t(e.rotationInput.rotation.x).toBeCloseTo(-Math.PI/2+.01)}),o("first-person camera follows actor eye offset",async()=>{const e=new g;e.position.set(1,2,3),e.rotation.y=Math.PI/2,e.object.updateMatrixWorld(!0);const{camera:o}=w(e);o.eyeHeight=1.5,o.offsetZ=.25,o.autoActivate=!1,await o.onInit(),o.updateCameraTransform(),t(o.camera.position.x).toBeCloseTo(1.25),t(o.camera.position.y).toBeCloseTo(3.5),t(o.camera.position.z).toBeCloseTo(3)}),o("first-person aim helpers write to caller-provided objects",async()=>{const e=new g;e.position.set(0,1,0);const{camera:o}=w(e);o.autoActivate=!1,await o.onInit();const a=new p,n=new p,s=new c;t(o.getAimOrigin(a)).toBe(a),t(o.getAimDirection(n)).toBe(n),t(o.getAimRay(s)).toBe(s),t(s.origin).toEqual(a),t(s.direction).toEqual(n),t(n.x).toBeCloseTo(0),t(n.y).toBeCloseTo(0),t(n.z).toBeCloseTo(1),t(n.length()).toBeCloseTo(1)}),o("first-person camera pitch input tilts aim downward when positive",async()=>{const{camera:e}=w();e.autoActivate=!1,await e.onInit(),e.rotationInput.rotateX(.25),e.updateCameraTransform();const o=e.getAimDirection(new p);t(o.y).toBeLessThan(0),t(o.z).toBeGreaterThan(0),t(o.length()).toBeCloseTo(1)}),o("first-person hidden objects restore their previous visibility",()=>{const{camera:e}=w(),o=new i,a=new i;a.visible=!1,e.hideObjects(o,a),t(o.visible).toBe(!1),t(a.visible).toBe(!1),e.restoreHiddenObjects(),t(o.visible).toBe(!0),t(a.visible).toBe(!1)}),o("first-person activation is idempotent",async()=>{const{camera:e,element:o}=w();e.autoActivate=!1,await e.onInit(),B();const n=a.spyOn(o,"addEventListener"),s=a.spyOn(o,"removeEventListener"),i=a.spyOn(document,"addEventListener"),r=a.spyOn(document,"removeEventListener");e.activate(),e.activate(),t(C(n,"pointerdown")).toBe(1),t(C(n,"keydown")).toBe(1),t(C(i,"pointerlockchange")).toBe(1),e.deactivate(),e.deactivate(),t(C(s,"pointerdown")).toBe(1),t(C(s,"keydown")).toBe(1),t(C(r,"pointerlockchange")).toBe(1)}),o("third-person activation remains idempotent",async()=>{const e=new g,o=f(),n=new u;n.actor=e,n.autoActivate=!1,await n.onInit(),B();const s=a.spyOn(o,"addEventListener"),i=a.spyOn(o,"removeEventListener"),r=a.spyOn(document,"addEventListener"),c=a.spyOn(document,"removeEventListener");n.activate(),n.activate(),t(C(s,"pointerdown")).toBe(1),t(C(s,"keydown")).toBe(1),t(C(r,"pointerlockchange")).toBe(1),n.deactivate(),n.deactivate(),t(C(i,"pointerdown")).toBe(1),t(C(i,"keydown")).toBe(1),t(C(c,"pointerlockchange")).toBe(1)}),o("third-person camera hides the cursor only after pointer lock is active",async()=>{const e=new g,o=f();document.body.appendChild(o);const n=o.querySelector("canvas"),s=new u;s.actor=e,s.autoActivate=!1,await s.onInit(),B();const i=a.fn(()=>Promise.resolve());Object.defineProperty(n,"requestPointerLock",{configurable:!0,value:i});let r=null;Object.defineProperty(document,"pointerLockElement",{configurable:!0,get:()=>r}),s.activate(),s.hideCursor({ignoreRecentUnlock:!0}),t(i).toHaveBeenCalledWith({unadjustedMovement:!0}),t(s.isMouseLocked).toBe(!1),t(o.style.cursor).not.toBe("none"),r=n,document.dispatchEvent(new Event("pointerlockchange")),t(s.isMouseLocked).toBe(!0),t(o.style.cursor).toBe("none"),o.remove()}),o("third-person camera does not treat sustained fast movement as a teleport",async()=>{const e=new g;f();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!0,o.smoothSpeed=10,await o.onInit(),o.onLateUpdate(1/60);for(let t=0;t<12;t++)e.position.x+=.2,o.onLateUpdate(1/60);t(e.position.x-o.camera.position.x).toBeGreaterThan(.7)}),o("third-person camera still snaps after a target teleport",async()=>{const e=new g;f();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!0,o.teleportSnapDistance=5,await o.onInit(),o.onLateUpdate(1/60),e.position.x=10,o.onLateUpdate(1/60),t(o.camera.position.x).toBeCloseTo(10)}),o("third-person collision casts along the current frame camera arm",async()=>{const e=new g;f();const o=s.get(h);a.mocked(o.sphereCast).mockReturnValue({hasHit:!1});const n=new u;n.actor=e,n.autoActivate=!0,n.fixedBehind=!1,await n.onInit(),n.rotationInput.rotation.y=Math.PI/2,n.onLateUpdate(1/60);const[,,i,r]=a.mocked(o.sphereCast).mock.calls[0],c=Math.sqrt(n.distance**2+(n.offsetY-n.height)**2);t(i.x).toBeCloseTo(-n.distance/c),t(i.y).toBeCloseTo((n.offsetY-n.height)/c),t(r).toBeCloseTo(c)}),o("third-person collision preserves the safe camera arm and recovers smoothly",async()=>{const e=new g;f();const o=s.get(h);a.mocked(o.sphereCast).mockReturnValueOnce({hasHit:!0,distance:4}).mockReturnValue({hasHit:!1});const n=new u;n.actor=e,n.autoActivate=!0,n.fixedBehind=!1,await n.onInit();const i=new p(0,n.height,0);n.onLateUpdate(1/60),t(n.camera.position.distanceTo(i)).toBeCloseTo(3.95),n.onLateUpdate(.1);const r=n.camera.position.distanceTo(i);t(r).toBeGreaterThan(3.95),t(r).toBeLessThan(Math.sqrt(n.distance**2+(n.offsetY-n.height)**2))}),o("third-person camera blends temporary control over the gameplay pose",async()=>{const e=new g;f();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!1,await o.onInit(),o.onLateUpdate(1/60);const a=o.camera.position.clone(),n=new p(10,4,2),s=new p(1,2,3);o.setTemporaryControl(n,s,{blendInDuration:1}),o.onLateUpdate(.5),t(o.camera.position.x).toBeCloseTo((a.x+n.x)/2),t(o.camera.position.y).toBeCloseTo((a.y+n.y)/2),o.onLateUpdate(.5),t(o.camera.position).toEqual(n)}),o("third-person camera blends temporary control back to the current gameplay pose",async()=>{const e=new g;f();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!1,await o.onInit();const a=new p(10,4,2);o.setTemporaryControl(a,new p(1,2,3),{blendInDuration:0}),o.onLateUpdate(1/60),e.position.x=2,o.clearTemporaryControl(1),o.onLateUpdate(.5);const n=new p(2,o.offsetY,-o.distance);t(o.camera.position.x).toBeCloseTo((a.x+n.x)/2),t(o.camera.position.y).toBeCloseTo((a.y+n.y)/2)}),o("third-person camera applies collision to temporary control",async()=>{const e=new g;f();const o=s.get(h);a.mocked(o.sphereCast).mockReturnValue({hasHit:!0,distance:2});const n=new u;n.actor=e,n.autoActivate=!0,n.collision=!0,n.fixedBehind=!1,n.smoothCamera=!1,await n.onInit(),n.setTemporaryControl(new p(0,0,10),new p,{blendInDuration:0}),n.onLateUpdate(1/60),t(n.camera.position.z).toBeCloseTo(1.95)}),o("third-person temporary shots exclude their subject and fall back when obstructed too close",async()=>{const e=new g,o=new g;f();const n=s.get(h);a.mocked(n.sphereCast).mockReturnValue({hasHit:!1});const i=new u;i.actor=e,i.autoActivate=!0,i.fixedBehind=!1,i.smoothCamera=!1,await i.onInit(),i.onLateUpdate(1/60);const r=i.camera.position.clone(),c=i.camera.quaternion.clone(),l=i.camera.fov;i.setTemporaryControl(new p(0,1.5,2.4),new p(0,1.5,0),{blendInDuration:0,fieldOfView:38,collisionExcludeActor:o}),a.mocked(n.sphereCast).mockReturnValueOnce({hasHit:!1}).mockReturnValueOnce({hasHit:!0,distance:0}),i.onLateUpdate(1/60),t(n.sphereCast).toHaveBeenLastCalledWith(t.any(p),i.collisionSphereRadius,t.any(p),t.any(Number),void 0,t.objectContaining({excludeActor:o})),t(i.camera.position).toEqual(r),t(i.camera.quaternion.angleTo(c)).toBeCloseTo(0),t(i.camera.fov).toBe(l),a.mocked(n.sphereCast).mockReturnValueOnce({hasHit:!1}).mockReturnValueOnce({hasHit:!0,distance:1.1}),i.onLateUpdate(1/60),t(i.camera.position.z).toBeCloseTo(1.05),t(i.camera.fov).toBe(38),t(n.sphereCast).toHaveBeenLastCalledWith(t.any(p),i.collisionSphereRadius,t.any(p),t.any(Number),void 0,t.objectContaining({stopAtPenetration:!1})),i.onLateUpdate(1/60),t(i.camera.position.distanceTo(new p(0,1.5,2.4))).toBeCloseTo(0),t(i.camera.fov).toBe(38)}),o("third-person camera pans toward a temporary look-at target without repositioning",async()=>{const e=new g;f();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,o.fixedBehind=!1,o.smoothCamera=!1,await o.onInit(),o.onLateUpdate(1/60);const a=o.camera.position.clone(),n=o.camera.quaternion.clone(),s=new p(0,2,-20),i=new r;i.position.copy(a),i.lookAt(s);const c=n.angleTo(i.quaternion);o.setTemporaryLookAt(s,{blendInDuration:1}),o.onLateUpdate(.5),t(o.camera.position).toEqual(a),t(n.angleTo(o.camera.quaternion)).toBeCloseTo(c/2)}),o("third-person camera blends a temporary field of view and restores it",async()=>{const e=new g;f();const o=new u;o.actor=e,o.autoActivate=!0,o.collision=!1,await o.onInit();const a=o.camera.fov;o.setTemporaryLookAt(new p(0,2,10),{blendInDuration:1,blendOutDuration:1,fieldOfView:35}),o.onLateUpdate(1),t(o.camera.fov).toBe(35),o.clearTemporaryControl(),o.onLateUpdate(.5),t(o.camera.fov).toBeCloseTo((35+a)/2),o.onLateUpdate(.5),t(o.camera.fov).toBe(a)});class g extends m{constructor(){super(),this.__isInitialised=!0}}for(const e of[d,u])o(e.name+" does not capture menu clicks while paused",async()=>{const o=new g,n=f(),i=new e;i.actor=o,i.autoActivate=!1,await i.onInit(),B(),i.activate(),i.isMouseLocked=!1;const r=a.spyOn(i,"hideCursor").mockImplementation(()=>{}),c=s.get(y),l=()=>{const e=new Event("pointerdown");Object.defineProperty(e,"pointerType",{value:"mouse"}),n.dispatchEvent(e)};c.paused=!0,l(),t(r).not.toHaveBeenCalled(),c.paused=!1,l(),t(r).toHaveBeenCalledOnce(),i.deactivate()});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{afterEach as e,beforeEach as t,describe as
|
|
1
|
+
import{afterEach as e,beforeEach as t,describe as o,expect as s,it as n,vi as l}from"vitest";import a from"three/examples/jsm/libs/stats.module.js";import{FrameStats as m}from"../utils/three/frame-stats.js";const{panels:r}=l.hoisted(()=>({panels:[]}));l.mock("three/examples/jsm/libs/stats.module.js",()=>({default:{Panel:class{constructor(e){this.name=e,this.dom=document.createElement("canvas"),this.update=l.fn(),r.push(this)}}}})),o("frame stats",()=>{let o=0;t(()=>{o=0,r.length=0,l.spyOn(performance,"now").mockImplementation(()=>o)}),e(()=>l.restoreAllMocks()),n("selects and cycles panels without DOM selector APIs",()=>{l.spyOn(Element.prototype,"querySelectorAll").mockImplementation(()=>{throw new Error("DOM selectors are unavailable")});const e=new m,t=e.addPanel(new a.Panel("Calls","#83f","#002"));e.showPanel(r.length-1),s(t.dom.style.display).toBe("block"),e.dom.click(),s(t.dom.style.display).toBe("none"),s(e.dom.firstElementChild?.getAttribute("style")).toContain("display: block")}),n("counts every frame but redraws milliseconds only four times a second",()=>{const e=new m;for(let t=1;t<=100;t++){o=10*t-(t%2==0?6:2),e.begin(),o=10*t,e.end()}const t=r.find(e=>"MS avg"===e.name).update,n=r.find(e=>"FPS"===e.name).update;s(t).toHaveBeenCalledTimes(4),s(t.mock.calls[0][0]).toBeCloseTo(3.92),s(t.mock.calls[1][0]).toBeCloseTo(4.08),s(n).toHaveBeenCalledExactlyOnceWith(100,100)}),n("discards hidden time and old measurements when stats are re-enabled",()=>{const e=new m;e.begin(),o=100,e.end(),o=1e4,e.reset();for(let t=1;t<=100;t++)o=1e4+10*t-3,e.begin(),o+=3,e.end();s(r.find(e=>"FPS"===e.name).update).toHaveBeenCalledExactlyOnceWith(100,100);const t=r.find(e=>"MS avg"===e.name).update;s(t).toHaveBeenCalledTimes(4);for(const e of t.mock.calls)s(e).toEqual([3,200])}),n("shows actual frame cadence in simple mode without updating diagnostic panels",()=>{const e=new m;e.style="simple",e.end();for(let t=1;t<=30;t++)o=t*(1e3/30)-2,e.begin(),o+=2,e.end();s(e.dom.textContent).toBe("30 FPS · 33.3 ms");for(const e of r)s(e.update).not.toHaveBeenCalled();for(const t of Array.from(e.dom.querySelectorAll("canvas")))s(t.style.display).toBe("none");s(e.dom.style.pointerEvents).toBe("none")}),n("resets simple samples when switching styles or re-enabling stats",()=>{const e=new m;e.style="simple",e.end(),o=250,e.end(),s(e.dom.textContent).toBe("4 FPS · 250.0 ms"),o=1e4,e.reset(),e.end();for(let t=1;t<=25;t++)o=1e4+10*t,e.end();s(e.dom.textContent).toBe("100 FPS · 10.0 ms"),e.style="detailed",s(e.dom.style.pointerEvents).toBe("");for(const t of Array.from(e.dom.querySelectorAll("canvas")))s(t.style.display).toBe("inline-block");e.style="simple",s(e.dom.textContent).toBe("— FPS · — ms"),e.dom.click();for(const t of Array.from(e.dom.querySelectorAll("canvas")))s(t.style.display).toBe("none")})});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,rgb as n,rgba as s,standardMaterial as r,textureSampler2d as l,uniformFloat as c,uniformSampler2d as p,uniformVec3 as m,varyingAttributes as d,vec3 as u}from"three-shader-graph";import{SerializedParamType as h}from"../scene/model.js";import{WorldCollisionMethod as y}from"../effects/vfx/vfx-asset.js";import{compileGpuSpriteEmitters as f,materializeGpuSpriteRenderClasses as g,requiresCpuVfxBackend as b,selectVfxBackend as w}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as C}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as v,GPU_TRAIL_RENDER_CLASS_BYTES as x,GpuSpriteDirtyRange as B,getCompiledGpuSpriteMaterial as S,getGpuSpriteComputeWgsl as V,getGpuSpriteMaterialWgsl as T,getGpuTrailMaterialWgsl as M}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as E,trailUV as I}from"../effects/vfx/trail-renderer.js";import{timeUniforms as P}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as O}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as j,instantiateGpuSpriteTemplate as z}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as q}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as A}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as R}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as k}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as G,ComputeKernel as L}from"@hology/webgpu-renderer";import{configureWebGpuRuntimeBindings as _}from"../rendering/webgpu-runtime-bridge.js";_({createComputeKernel:(e,t)=>new L(e,t),buildParticleLightingShaderHeader:G});import{sampleSceneColor as U}from"../shader-nodes/scene-sample.js";import{screenUV as D}from"../shader-nodes/depth.js";import{particleUniforms as F}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as W}from"../effects/vfx/vfx-gpu-material.js";const H=e=>({type:h.Number,value:e}),N=e=>({type:h.Vector3,value:e});function Z(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function X(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function Y(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function $(e,t=Z()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:H(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:N([0,0,0]),radius:H(1),surface:{type:h.Boolean,value:!1}}}],behaviours:[]}}function Q(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a("keeps standard asset materials lit for GPU mesh particles",async()=>{const e=new o.Texture,a=new o.Texture,n=new o.Texture,s=new i({color:r({color:m("color",new o.Vector3(.3,.2,.1)).rgb.multiply(p("map",e).sample(d.uv).rgb),roughness:c("roughness",.8).multiply(p("roughnessMap",a).sample(d.uv).g),normal:p("normalMap",n).sample(d.uv).rgb,metalness:.1}),opacity:c("opacity",1)}),l=await W("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),u=S(l),h=T(u,!0);t(u.requiresRendererShaderHeader).toBe(!0),t(u.needsWorldPosition).toBe(!0),t(u.fragmentMain).toContain("rendererPointLightForLight"),t(u.fragmentMain).toContain("rendererDirectionalLightForLight"),t(h).toContain("fn rendererPointLightCount()"),t(u.textures.map).toBe(e),t(u.textures.roughnessMap).toBe(a),t(u.textures.normalMap).toBe(n)}),a("bakes an authored effect input into emitter spawn delay",()=>{const e=$("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=Q(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=f(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate.delay).toBe(.5)}),a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=V(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnOffset(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("localInitialVelocity += randomConeDirection(sampledMeshNormal"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=$("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:H(.6),friction:H(.2),lifeLoss:H(.5),debug:{type:h.Boolean,value:!0}}});const a=$("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=f(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=V(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,0,!1,!0);t(s).toContain("var<storage, read_write> optionalStates"),t(s).toContain("let triangleRecord = optionalStates[triangleOffset]"),t(s).toContain("fn meshSamplerBoneMatrix"),t(s).toContain("meshSamplerVertexPosition(samplerOffset"),t(s).toContain("var triangleOffset = samplerOffset"),t(s).not.toContain("let triangleOffset = samplerOffset");const r=V(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(r).toContain("collisionDepth"),t(r).toContain("collisionStep <= 6u"),t(r).toContain("approachingVisibleSurface"),t(r).toContain("optionalStates[32u + owner.padding]"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=$("parent",Z({space:"local"})),a=$("local-child",Z({space:"local"})),i=$("world-child",Z({space:"world"}));e.children=[a,i];const n=f(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=V(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("invalidates recycled parent spawn events during a new activation delay",()=>{const e=$("delayed-impact",Z({space:"world"}));e.rate.delay=.13,e.children=[$("ground-impact",Z({space:"world"}))];const a=f(Q(e),[new o.Vector3(12,0,8)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].rate.delay).toBe(.13);const i=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(i).toContain("if (!emissionStarted || !stopAllowsEmission || totalSpawn <= simulationLocalSlot)"),t(i).toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;"),t(i.indexOf("particle.scaleAlphaEmitterGeneration.w = 0.0;")).toBeLessThan(i.indexOf("particles[particleIndex] = particle;")),t(i).not.toMatch(/__[A-Z_]+__/),t(V(16)).not.toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;")}),a("rejects world-space parents with local-space child output",()=>{const e=$("parent",Z({space:"world"}));e.children=[$("child",Z({space:"local"}))];const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=$("parent",X({renderOrder:10})),a=$("child",Z({renderOrder:-10}));e.children=[a];const i=f(Q(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(k)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=f(Q($("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await g(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=T(S(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=f(Q($("trail",Y({length:9.6,uvMode:"repeat",unitsPerRepeat:.5}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await g(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0,uvMode:"repeat",unitsPerRepeat:.5}),t(a.renderClasses[0].texture?.wrapS).toBe(o.RepeatWrapping),t(x).toBe(96);const i=V(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=f(Q($("attached-trail",Y({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=V(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:s(u(I.x,I.y,F.opacity),F.opacity)});E.applyTrailShaderParameters(e);const a=M(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("nodeDistance: f32"),t(a).toContain("renderClass.uvSettings.x"),t(a).toContain("headDistance - mix(historyDistance(state, control), historyDistance(state, control + 1), t)"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:s(u(P.elapsed),1)}),a=M(e),o=T(S(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("converts authored sRGB behavior colors to the linear render space",()=>{const e=$("legacy-color",Y());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#918383"},b:{type:h.Color,value:"#4f4f4f"}}});const a=f(Q(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual(new o.Color("#918383").toArray()),t(a.emitters[0].colorEnd.toArray()).toEqual(new o.Color("#4f4f4f").toArray())}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=$("mesh",X({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:N([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:N([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:N([2,1,1]),b:N([1,.5,2])}});const a=Q(e),r=f(a,[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.renderClasses[0].output.type).toBe("mesh"),t(r.emitters[0].localSpace).toBe(!0),t(r.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(r.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(r.emitters[0].meshRotation.w).not.toBe(1),t(r.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const l=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),c=await g(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l});t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(c.renderClasses[0].meshOpacity).toBe(.75),t(c.renderClasses[0].localSpace).toBe(!0);const p=new i({color:s(n(.4),1)}),m=await g(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const d=new o.Group;d.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(g(r.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>d})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=f(Q($("unconfigured-mesh",X({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=f(Q($("unselected-mesh-material",X({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:s(n(.4),1)}),r=await g(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(r.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=f(Q($("unselected-mesh-graph",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await g(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>S(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=f(Q($("mesh-material",X({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(g(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await g(e.renderClasses,a,async()=>new i({color:l(n).sample(d.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=S(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=f(Q($("oriented-trail",Y({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await g(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=V(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=M(E.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=f(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:H(1)}}))),[new o.Vector3]),i=f(Q($("mesh",X({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:H(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=f(Q($("modern",X())),[new o.Vector3]),n=f(Q($("legacy",X({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await g(i.renderClasses,s),l=await g(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=Q($("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new A},i=await(new j).resolve(e,{...a,revision:1}),n=await(new j).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:U(D)}),a=S(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=S(new i({color:r({color:n(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(G()).toContain("fn rendererCameraPosition()");const a=T(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;R({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new B;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=V(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=$("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:H(.5),maxDuration:H(2.5)}};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=$("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:N([1,2,3])},0],["positionSphere",{position:N([1,2,3]),radius:H(2),surface:{type:h.Boolean,value:!0}},1],["positionBox",{position:N([1,2,3]),dimensions:N([4,5,6])},2],["positionLine",{a:N([1,2,3]),b:N([4,5,6])},3],["positionRing",{position:N([1,2,3]),rotation:N([10,20,30]),radius:H(4),thickness:H(.5),angle:H(180)},4]];for(const[a,i,n]of e){const e=$(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=f(Q(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=V(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=$("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:N([0,0,200])}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=V(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=X();const n=f(Q(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=V(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:h.String,value:"Y"}}});const r=f(Q(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=$("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:H(4),direction:N([0,1,0]),spread:H(30)}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=V(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=$("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:N([1,2,3]),force:H(4),radius:H(5)}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=f(Q(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=V(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=$("aligned-mesh",X());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:h.String,value:"+X"},mode:{type:h.String,value:"AlongVelocity"},offset:N([10,20,30]),center:N([1,2,3])}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=V(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=f(Q($("a"),$("b"),$("c",Z({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=Q($("first"),$("second",Z({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=f(e,a),n=f(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await C({asset:Q($("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await O.create({asset:Q($("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=V(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await C({asset:Q($("additive"),$("normal",Z({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=V(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=$("paged-instance");e.position=[2,0,0];const a=await O.create({asset:Q(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await O.create({asset:Q($("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await O.create({asset:Q($("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=$("delayed");e.rate={type:"once",count:2,delay:.25};const a=$("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=f(Q(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=$("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=Q($("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(f(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=f(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(f(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=$("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=f(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=Q($("cached-template")),a=new j;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=z(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=$("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:h.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=Q(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await O.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=Z(),a=await q.create({templates:[{asset:Q($("world-a",e)),maximumInstances:2},{asset:Q($("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new j,renderClassCache:new A,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=$("world-lifecycle");e.position=[2,0,0];const a=await q.create({templates:[{asset:Q(e),maximumInstances:1},{asset:Q($("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await q.create({templates:[{asset:Q($("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=$("soft-local",Z({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=f(Q(e),[new o.Vector3]),i=w(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(w(a.compatibility,"gpu").backend).toBe("gpu")}),a("intentionally selects CPU simulation for physics-raycast world collision",()=>{const e=$("physics-collision");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{method:{type:h.String,value:y.PhysicsRaycast}}});const a=Q(e),i=f(a,[new o.Vector3]);t(b(a)).toBe(!0),t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues).toContainEqual(t.objectContaining({severity:"requirement",code:"physics-raycast-collision"})),t(w(i.compatibility).backend).toBe("cpu"),t(w(i.compatibility,"gpu").backend).toBe("cpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=$("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:N([0,5,0]),amount:H(1.25)}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(V(16,!1,!1)).not.toContain("vortexVelocities");const i=V(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=$("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:H(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:N([1,2,3]),delay:H(.25),ease:{type:h.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:H(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:N([5,6,7]),ease:{type:h.String,value:"InQuad"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=$("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:H(0),b:H(1),ease:{type:h.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:h.Color,value:"#ff0000"},b:{type:h.Color,value:"#0000ff"},ease:{type:h.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:H(0),b:H(1),ease:{type:h.String,value:"BumpSharp"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=V(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=$("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:H(3),axis:{type:h.String,value:"XZ"},ease:{type:h.String,value:"InOutSine"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(V(16)).not.toContain("disperseStates");const i=V(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=V(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(v({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,meshSamplerOffset:100,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=$("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(r)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=$("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const r=new i({color:s(n(1),1)}),l=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=S(new i({color:s(n(1),1)})),a=T(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=$("soft-sprite",Z({softness:2})),a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await g(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=S(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=$("graph-sprite",Z({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=f(Q(e),[new o.Vector3]),r=new i({color:s(n(1),.5),transparent:!0}),l=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>r);t(l.renderClasses[0].material).toBe(r),t(r.depthWrite).toBe(!1),t(S(r).renderState.depthWrite).toBe(!1);const c=new i({color:s(n(1),1)}),p=await g(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(S(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=$("local-sprite",Z({space:"local"})),a=f(Q(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await g(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=$("graph",Z({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await C({asset:Q(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=$("flipbook",Z({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=f(Q(e),[new o.Vector3]),i=await g(a.renderClasses,{getTexture:async()=>new o.Texture}),n=S(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=$("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:N([.25,1,1]),b:N([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=f(Q(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=$("input-driven",X());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...H(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...N([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...N([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=Q(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=f(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})}),a("shares default sprite shader source across numeric settings without merging material bindings",async()=>{const e=Z({softness:1,flipbook:{enabled:!0,columns:4,rows:4,fps:12,mode:"loop"}}),a=Z({softness:5,flipbook:{enabled:!0,columns:8,rows:8,fps:64,mode:"loop"}}),i=f(Q($("first",e),$("second",a)),[new o.Vector3]);t(i.renderClasses).toHaveLength(2);const n=new o.Texture,s=await g(i.renderClasses,{getTexture:async()=>n}),r=s.renderClasses[0].material,l=s.renderClasses[1].material;t(r.uniforms.vfxSoftness.value).toBe(.2),t(l.uniforms.vfxSoftness.value).toBe(1),t(r.uniforms.vfxFlipbookColumns.value).toBe(4),t(l.uniforms.vfxFlipbookColumns.value).toBe(8),t(r.uniforms.vfxFlipbookFps.value).toBe(12),t(l.uniforms.vfxFlipbookFps.value).toBe(64),t(T(S(r),!0)).toBe(T(S(l),!0));const c=f(Q($("first",e),$("second",{...a,softness:1})),[new o.Vector3]);t(c.renderClasses).toHaveLength(2)});/*
|
|
1
|
+
import{describe as e,expect as t,it as a}from"vitest";import*as o from"three";import{NodeShaderMaterial as i,UniformSampler2dArraySlice as n,rgb as s,rgba as r,standardMaterial as l,textureSampler2d as c,uniformFloat as p,uniformSampler2d as m,uniformVec3 as d,varyingAttributes as u,vec3 as h}from"three-shader-graph";import{SerializedParamType as y}from"../scene/model.js";import{WorldCollisionMethod as f}from"../effects/vfx/vfx-asset.js";import{compileGpuSpriteEmitters as g,materializeGpuSpriteRenderClasses as b,requiresCpuVfxBackend as w,selectVfxBackend as C}from"../effects/vfx/gpu-sprite-compiler.js";import{prepareGpuSpriteBackend as v}from"../effects/vfx/gpu-sprite-backend.js";import{calculateGpuSpriteOptionalStateLayout as x,GPU_TRAIL_RENDER_CLASS_BYTES as B,GpuSpriteDirtyRange as S,getCompiledGpuSpriteMaterial as T,getGpuSpriteComputeWgsl as V,getGpuSpriteMaterialWgsl as M,getGpuTrailMaterialWgsl as E}from"../effects/vfx/gpu-sprite-runtime.js";import{Trail as I,trailUV as P}from"../effects/vfx/trail-renderer.js";import{timeUniforms as O}from"../shader-nodes/time.js";import{GpuSpriteSchedulerRuntime as j}from"../effects/vfx/gpu-sprite-scheduler-runtime.js";import{GpuSpriteTemplateCache as z,instantiateGpuSpriteTemplate as A}from"../effects/vfx/gpu-sprite-template.js";import{GpuSpriteWorldRuntime as q}from"../effects/vfx/gpu-sprite-world-runtime.js";import{GpuSpriteRenderClassCache as R}from"../effects/vfx/gpu-sprite-compiler.js";import{addGpuSpriteRenderGraphPasses as G}from"../effects/vfx/gpu-sprite-render-graph.js";import{gpuVfxCapacityClass as k}from"../effects/vfx/gpu-vfx-capacity.js";import{buildParticleLightingShaderHeader as _,ComputeKernel as L}from"@hology/webgpu-renderer";import{configureWebGpuRuntimeBindings as D}from"../rendering/webgpu-runtime-bridge.js";D({createComputeKernel:(e,t)=>new L(e,t),buildParticleLightingShaderHeader:_});import{sampleSceneColor as U}from"../shader-nodes/scene-sample.js";import{screenUV as F}from"../shader-nodes/depth.js";import{particleUniforms as W}from"../shader-nodes/particle.js";import{createGpuMeshAssetMaterial as H}from"../effects/vfx/vfx-gpu-material.js";const N=e=>({type:y.Number,value:e}),Z=e=>({type:y.Vector3,value:e});function X(e={}){return{type:"sprite",texture:"dot",color:"#ffffff",intensity:1,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}function Y(e={}){return{type:"mesh",assetId:"simple-mesh",space:"world",...e}}function $(e={}){return{type:"trail",length:10,width:.5,taper:!0,color:"#40a0ff",colorEnd:"#ff4080",intensity:2,intensityEnd:.25,opacityStart:1,opacityEnd:0,bloom:!1,billboard:!0,opacityChannel:"alpha",space:"world",...e}}function Q(e,t=X()){return{id:e,name:e,position:[0,0,0],rotation:[0,0,0],rate:{type:"once",count:2,delay:0},output:t,initializers:[{id:`${e}-life`,type:"lifetime",enabled:!0,params:{duration:N(2)}},{id:`${e}-position`,type:"positionSphere",enabled:!0,params:{position:Z([0,0,0]),radius:N(1),surface:{type:y.Boolean,value:!1}}}],behaviours:[]}}function K(...e){return{id:"test-vfx",name:"test-vfx",type:"vfx",vfx:{emitters:e}}}e("GPU sprite compiler compatibility",()=>{a.each(["data","compressed"])("declares %s array textures for sprites, mesh particles and trails",e=>{const a="data"===e?new o.DataArrayTexture(new Uint8Array(8),1,1,2):new o.CompressedArrayTexture([],4,4,2,o.RGBA_S3TC_DXT1_Format),s=new n("arrayMap",a,p("layer",1)),r=new i({color:s.sample(u.uv)}),l=T(r);for(const e of[M(l),M(l,!0),E(r)])t(e).toContain("var arrayMapTexture: texture_2d_array<f32>;"),t(e).toMatch(/textureSample\(arrayMapTexture, arrayMapSampler, .*i32\(round\(/);a.dispose(),r.dispose()}),a("keeps standard asset materials lit for GPU mesh particles",async()=>{const e=new o.Texture,a=new o.Texture,n=new o.Texture,s=new i({color:l({color:d("color",new o.Vector3(.3,.2,.1)).rgb.multiply(m("map",e).sample(u.uv).rgb),roughness:p("roughness",.8).multiply(m("roughnessMap",a).sample(u.uv).g),normal:m("normalMap",n).sample(u.uv).rgb,metalness:.1}),opacity:p("opacity",1)}),r=await H("material-id",{getAsset:async()=>({id:"material-id"}),getTexture:async()=>e,getMesh:async()=>new o.Mesh,getMaterial:async()=>s}),c=T(r),h=M(c,!0);t(c.requiresRendererShaderHeader).toBe(!0),t(c.needsWorldPosition).toBe(!0),t(c.fragmentMain).toContain("rendererPointLightForLight"),t(c.fragmentMain).toContain("rendererDirectionalLightForLight"),t(h).toContain("fn rendererPointLightCount()"),t(c.textures.map).toBe(e),t(c.textures.roughnessMap).toBe(a),t(c.textures.normalMap).toBe(n)}),a("bakes an authored effect input into emitter spawn delay",()=>{const e=Q("bound-delay");e.rate.delayBinding={type:"vfxInput",inputId:"timing"};const a=K(e);a.vfx.inputs=[{id:"timing",name:"Timing",type:"number",defaultValue:.5}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate.delay).toBe(.5)}),a("bakes the emitter rotation into world-space spawn position, velocity, and mesh rotation",()=>{const e=V(16,!0,!1,!1,!1,!0);t(e).toContain("zoneOffset = worldSpawnOffset(emitter, zoneOffset)"),t(e).toContain("let initialVelocity = worldSpawnDirection(emitter, localInitialVelocity)"),t(e).toContain("localInitialVelocity += randomConeDirection(sampledMeshNormal"),t(e).toContain("worldSpawnRotation(emitter), quaternionFromEulerXYZ(initialRotationEuler)"),t(e).toContain("optionalStates[meshStateOffset], quaternionFromEulerXYZ(rotationDelta)"),t(e).not.toContain("optionalStates[meshStateOffset] = quaternionFromEulerXYZ(rotationEuler)"),t(e).toContain("emitter.trailTransform0 = command.trailTransform0"),t(e).not.toMatch(/__[A-Z_]+__/)}),a("compiles pay-for-play child emitters and screen-space collision metadata",()=>{const e=Q("parent");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{bounce:N(.6),friction:N(.2),lifeLoss:N(.5),debug:{type:y.Boolean,value:!0}}});const a=Q("child");a.rate={type:"once",count:3,delay:0},a.spawnEvent="collision",e.children=[a];const i=g(K(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters).toHaveLength(2),t(i.emitters[0]).toMatchObject({hasWorldCollision:!0,collisionBounce:.6,collisionFriction:.2,collisionLifeLoss:.5,collisionDebug:!0}),t(i.emitters[1]).toMatchObject({parentEmitter:0,childParticlesPerParent:3,childSpawnEvent:2,childLevel:1,capacity:6});const n=V(16);t(n).not.toContain("collisionDepth"),t(n).not.toContain("optionalStates"),t(n).not.toContain("triangleRecord"),t(n).not.toMatch(/__[A-Z_]+__/);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,0,!1,!0);t(s).toContain("var<storage, read_write> optionalStates"),t(s).toContain("let triangleRecord = optionalStates[triangleOffset]"),t(s).toContain("fn meshSamplerBoneMatrix"),t(s).toContain("meshSamplerVertexPosition(samplerOffset"),t(s).toContain("var triangleOffset = samplerOffset"),t(s).not.toContain("let triangleOffset = samplerOffset");const r=V(16,!1,!1,!1,!1,!1,!1,0,0,!0,!0,32,1);t(r).toContain("collisionDepth"),t(r).toContain("collisionStep <= 6u"),t(r).toContain("approachingVisibleSurface"),t(r).toContain("optionalStates[32u + owner.padding]"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("supports local-space parent particles spawning local or world-space children",()=>{const e=Q("parent",X({space:"local"})),a=Q("local-child",X({space:"local"})),i=Q("world-child",X({space:"world"}));e.children=[a,i];const n=g(K(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.compatibility.issues).toEqual([]),t(n.emitters.map(e=>e.localSpace)).toEqual([!0,!0,!1]);const s=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(s).not.toContain("parentTransform = mat4x4");const r=V(16,!1,!1,!1,!1,!0,!1,0,0,!1,!1,0,1,!0);t(r).toContain("parentTransform = mat4x4"),t(r).toContain("parentEmitter.trail.z > 0.0 && emitter.trail.z < 0.0"),t(r).toContain("childEmissionEnd = parent.velocityLife.w"),t(r).toContain("particle.positionAge = vec4(particle.positionAge.xyz, age)"),t(r).not.toContain("childEventReady = parentAlive"),t(r).not.toMatch(/__[A-Z_]+__/)}),a("invalidates recycled parent spawn events during a new activation delay",()=>{const e=Q("delayed-impact",X({space:"world"}));e.rate.delay=.13,e.children=[Q("ground-impact",X({space:"world"}))];const a=g(K(e),[new o.Vector3(12,0,8)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].rate.delay).toBe(.13);const i=V(16,!1,!1,!1,!1,!1,!1,0,0,!1,!1,0,1);t(i).toContain("if (!emissionStarted || !stopAllowsEmission || totalSpawn <= simulationLocalSlot)"),t(i).toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;"),t(i.indexOf("particle.scaleAlphaEmitterGeneration.w = 0.0;")).toBeLessThan(i.indexOf("particles[particleIndex] = particle;")),t(i).not.toMatch(/__[A-Z_]+__/),t(V(16)).not.toContain("particle.scaleAlphaEmitterGeneration.w = 0.0;")}),a("rejects world-space parents with local-space child output",()=>{const e=Q("parent",X({space:"world"}));e.children=[Q("child",X({space:"local"}))];const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues).toContainEqual(t.objectContaining({code:"child-emitter-local-space"}))}),a("orders child render classes by authored render order without changing simulation hierarchy",()=>{const e=Q("parent",Y({renderOrder:10})),a=Q("child",X({renderOrder:-10}));e.children=[a];const i=g(K(e),[new o.Vector3]);t(i.renderClasses.map(e=>e.output.renderOrder)).toEqual([-10,10]),t(i.emitters[0].parentEmitter).toBeUndefined(),t(i.emitters[0].renderClass).toBe(1),t(i.emitters[1]).toMatchObject({parentEmitter:0,renderClass:0})}),a("uses geometric world reservation classes without fixed template maxima",()=>{t([1,2,3,4,5,17].map(k)).toEqual([1,2,4,4,8,32])}),a("compiles stretched sprites into a velocity-aligned render class",async()=>{const e=g(K(Q("stretched",function(e={}){return{type:"stretchedSprite",texture:"dot",color:"#ffffff",intensity:1,scale:.5,blendingMode:"additive",softness:0,bloom:!1,space:"world",opacityChannel:"alpha",...e}}({scale:.75}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await b(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].stretchScale).toBe(.75);const i=M(T(a.renderClasses[0].material),!1);t(i).toContain("stretchedSpriteClipPosition"),t(i).toContain("bitcast<f32>(renderClass.data.w)")}),a("materializes billboard trails with CPU-compatible history sizing",async()=>{const e=g(K(Q("trail",$({length:9.6,uvMode:"repeat",unitsPerRepeat:.5}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.emitters).toHaveLength(1),t(e.emitters[0].capacity).toBe(2);const a=await b(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({historyLength:11,width:.5,taper:!0,uvMode:"repeat",unitsPerRepeat:.5}),t(a.renderClasses[0].texture?.wrapS).toBe(o.RepeatWrapping),t(B).toBe(96);const i=V(16,!1,!1,!1,!0);t(i).toContain("@group(0) @binding(8) var<storage, read_write> optionalStates"),t(i).not.toContain("@binding(9)")}),a("streams local trail transforms through emitter commands without another storage binding",()=>{const e=g(K(Q("attached-trail",$({space:"local"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=V(16,!1,!1,!1,!0,!0);t(a).toContain("trailTransform0: vec4<f32>"),t(a).toContain("emitter.trailTransform3 = command.trailTransform3"),t(a).not.toContain("@binding(12)")}),a("compiles trail graph UV varyings into the procedural ribbon ABI",()=>{const e=new i({color:r(h(P.x,P.y,W.opacity),W.opacity)});I.applyTrailShaderParameters(e);const a=E(e,!0);t(a).toContain("struct GraphVertexInput"),t(a).toContain("nodeVertexID: f32"),t(a).toContain("nodeDistance: f32"),t(a).toContain("renderClass.uvSettings.x"),t(a).toContain("headDistance - mix(historyDistance(state, control), historyDistance(state, control + 1), t)"),t(a).toContain("emitterTransforms"),t(a).not.toContain("__TRAIL_"),t(a).not.toContain("output.uv ="),t(a).not.toContain("output.color ="),t(a).not.toContain("normalize(after - before)"),t(a).not.toContain("2.0, 2.0, 2.0, 0.0")}),a("binds shader graph elapsed time to the GPU particle frame clock",()=>{const e=new i({color:r(h(O.elapsed),1)}),a=E(e),o=M(T(e),!1);t(a).toContain("frame.time"),t(o).toContain("frame.time"),t(a).not.toContain("hology_elapsed_time:"),t(o).not.toContain("hology_elapsed_time:")}),a("converts authored sRGB behavior colors to the linear render space",()=>{const e=Q("legacy-color",$());e.behaviours.push({id:"color",type:"changeColor",enabled:!0,params:{a:{type:y.Color,value:"#918383"},b:{type:y.Color,value:"#4f4f4f"}}});const a=g(K(e),[new o.Vector3]);t(a.emitters[0].colorStart.toArray()).toEqual(new o.Color("#918383").toArray()),t(a.emitters[0].colorEnd.toArray()).toEqual(new o.Color("#4f4f4f").toArray())}),a("materializes one mesh geometry and rejects mesh hierarchies with multiple geometries",async()=>{const e=Q("mesh",Y({space:"local"}));e.initializers.push({id:"mesh-scale-components",type:"scaleComponents",enabled:!0,params:{scale:Z([1,2,3])}},{id:"mesh-rotation",type:"rotation",enabled:!0,params:{rotation:Z([30,45,60])}}),e.behaviours.push({id:"mesh-scale-components-behaviour",type:"scaleComponents",enabled:!0,params:{a:Z([2,1,1]),b:Z([1,.5,2])}});const a=K(e),n=g(a,[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.renderClasses[0].output.type).toBe("mesh"),t(n.emitters[0].localSpace).toBe(!0),t(n.emitters[0].meshScaleStart.toArray()).toEqual([2,2,3]),t(n.emitters[0].meshScaleEnd.toArray()).toEqual([1,1,6]),t(n.emitters[0].meshRotation.w).not.toBe(1),t(n.emitters[0].meshRotationEuler.toArray()).toEqual([o.MathUtils.degToRad(30),o.MathUtils.degToRad(45),o.MathUtils.degToRad(60)]);const l=new o.Mesh(new o.BoxGeometry(1,1,1),new o.MeshBasicMaterial({color:3368601,opacity:.75})),c=await b(n.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l});t(c.renderClasses[0].meshVertices?.length).toBe(288),t(c.renderClasses[0].meshColor?.getHex()).toBe(3368601),t(c.renderClasses[0].meshOpacity).toBe(.75),t(c.renderClasses[0].localSpace).toBe(!0);const p=new i({color:r(s(.4),1)}),m=await b(n.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>l},async()=>p);t(m.renderClasses[0].material).toBe(p);const d=new o.Group;d.add(new o.Mesh(new o.BoxGeometry),new o.Mesh(new o.BoxGeometry)),await t(b(n.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>d})).rejects.toThrow("exactly one mesh geometry")}),a("keeps an unconfigured mesh output GPU-compatible without rendering it",()=>{const e=g(K(Q("unconfigured-mesh",Y({assetId:null}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.compatibility.issues).toEqual([]),t(e.emitters).toEqual([]),t(e.renderClasses).toEqual([])}),a("treats an unselected mesh material override as the default material source",async()=>{const e=g(K(Q("unselected-mesh-material",Y({materialSource:"material",material:void 0}))),[new o.Vector3]),a=new i({color:r(s(.4),1)}),n=await b(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)},async()=>a);t(e.compatibility.compatible).toBe(!0),t(n.renderClasses[0].material).toBe(a)}),a("keeps an unselected mesh shader graph on the GPU default material",async()=>{const e=g(K(Q("unselected-mesh-graph",Y({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:null}}))),[new o.Vector3]),a=await b(e.renderClasses,{getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)});t(e.compatibility.compatible).toBe(!0),t(()=>T(a.renderClasses[0].material)).not.toThrow()}),a("gates custom mesh materials on a compatible GPU material resolver",async()=>{const e=g(K(Q("mesh-material",Y({material:"material-id"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a={getTexture:async()=>new o.Texture,getMesh:async()=>new o.Mesh(new o.BoxGeometry)};await t(b(e.renderClasses,a)).rejects.toThrow("compatible GPU material resolver");const n=new o.Texture,s=await b(e.renderClasses,a,async()=>new i({color:c(n).sample(u.uv)}));t(s.renderClasses[0].material).toBeInstanceOf(i);const r=T(s.renderClasses[0].material);t(Object.values(r.textures)).toEqual([n])}),a("materializes non-billboard trails with orientation history",async()=>{const e=g(K(Q("oriented-trail",$({billboard:!1}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0);const a=await b(e.renderClasses,{getTexture:async()=>new o.Texture});t(a.renderClasses[0].trail).toMatchObject({billboard:!1});const i=V(16,!0,!1,!1,!0);t(i).toContain("trailSampleOffset + 1u"),t(i).toContain("quaternionMultiply(particleRotation, movementRotation)");const n=E(I.createBaseMaterial(),!1);t(n).toContain("renderClass.trail.w == 0u"),t(n).toContain("historySide(state, control)")}),a("changes the mesh render class when shader graph parameters change",()=>{const e={source:"asset",assetId:"mesh-graph"},a=g(K(Q("mesh",Y({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(1)}}))),[new o.Vector3]),i=g(K(Q("mesh",Y({materialSource:"shaderGraph",shaderGraph:e,shaderGraphParams:{intensity:N(2)}}))),[new o.Vector3]);t(a.renderClasses[0].key).not.toBe(i.renderClasses[0].key)}),a("preserves legacy mesh geometry transforms when requested",async()=>{const e=new o.Group,a=new o.Mesh((new o.BufferGeometry).setAttribute("position",new o.Float32BufferAttribute([1,0,0],3)));a.position.set(2,0,0),e.add(a);const i=g(K(Q("modern",Y())),[new o.Vector3]),n=g(K(Q("legacy",Y({useLegacyTransform:!0}))),[new o.Vector3]);t(i.renderClasses[0].key).not.toBe(n.renderClasses[0].key);const s={getTexture:async()=>new o.Texture,getMesh:async()=>e},r=await b(i.renderClasses,s),l=await b(n.renderClasses,s);t(r.renderClasses[0].meshVertices?.[0]).toBe(3),t(l.renderClasses[0].meshVertices?.[0]).toBe(1)}),a("does not reuse render classes across explicit template revisions",async()=>{const e=K(Q("revisioned")),a={assets:{getTexture:async()=>new o.Texture},renderClassCache:new R},i=await(new z).resolve(e,{...a,revision:1}),n=await(new z).resolve(e,{...a,revision:2});t(i.template?.renderClasses[0]).not.toBe(n.template?.renderClasses[0])}),a("retains renderer-owned scene texture semantics for particle materials",()=>{const e=new i({color:U(F)}),a=T(e);t(Object.values(a.sceneTextures)).toEqual(["sceneColor"])}),a("retains the shared renderer geometry semantics for lit particle materials",()=>{const e=T(new i({color:l({color:s(6724044),roughness:.5,metalness:.1})}));t(e.needsWorldPosition).toBe(!0),t(e.needsWorldNormal).toBe(!0),t(e.requiresRendererShaderHeader).toBe(!0),t(e.fragmentMain).toContain("rendererDirectionalLightForLight"),t(_()).toContain("fn rendererCameraPosition()");const a=M(e,!0);t(a).toContain("fn rendererCameraPosition()"),t(a).toContain("rendererCameraPosition() - input.worldPosition"),t(a).toContain("@group(1) @binding(0)"),t(a).toContain("emitterTransform[0].xyz"),t(a).not.toContain("mat3x3<f32>(emitterTransforms["),t(a).toContain("fn normalMatrixFromLinearTransform"),t(a).not.toContain("inverse("),t(e.renderState.transparent).toBe(!1),t(e.renderState.depthWrite).toBe(!0),t(e.renderState.side).toBe(o.FrontSide)}),a("registers one ordered compute/render pair for a shared runtime",()=>{const e=[];let a;G({mainDepth:"depth",addPass:t=>e.push(t)},{executeCompute:()=>{},executeRender:(e,t)=>{a=t},getSceneTextureReads:()=>["sceneColorSnapshot"]},"color","WorldVfx"),t(e.map(e=>e.name)).toEqual(["WorldVfxSimulation","WorldVfxRender"]),t(e[1].dependsOn).toEqual(["WorldVfxSimulation"]),t(e[1].inputs).toMatchObject({sceneColorSnapshot:"sceneColorSnapshot"}),e[1].execute({}),t(a).toBe("color")}),a("coalesces touched emitter slots without allocating per mark",()=>{const e=new S;e.mark(7),e.mark(3),e.mark(5),t(e).toMatchObject({start:3,end:8,length:5}),e.clear(),t(e.length).toBe(0)}),a("generates particle-owner WGSL without reserved active identifiers",()=>{const e=V(16);t(e).toContain("enabled: u32"),t(e).not.toMatch(/\bactive\s*:/),t(e).not.toContain("owner.active"),t(e).toContain("fn applyEmitterCommandsMain"),t(e).toContain("@group(0) @binding(7) var<storage, read> emitterCommands"),t(e).toContain("var<storage, read_write> emitters"),t(e).toContain("timing: vec4<f32>"),t(e).toContain("let emitterDelta = select(frame.delta * emitter.timing.w, 0.0"),t(e).toContain("let sampledLife = mix(emitter.emission.z, emitter.emission.w"),t(e).toContain("particle.velocityLife = vec4(initialVelocity, particleLife)"),t(e).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, hash(particleSeed + 71u))"),t(e).toContain("select(animatedColor, particle.color.rgb, emitter.meshScaleStart.w > 0.5)")}),a("compiles random lifetime as a per-particle birth range",()=>{const e=Q("random-life");e.initializers[0]={id:"random-life-range",type:"randomLifetime",enabled:!0,params:{minDuration:N(.5),maxDuration:N(2.5)}};const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({lifeMin:.5,life:2.5})}),a("compiles random initial color without lifetime interpolation",()=>{const e=Q("random-color");e.initializers.push({id:"random-color-range",type:"randomColor",enabled:!0,params:{a:{type:y.Color,value:"#ff0000"},b:{type:y.Color,value:"#0000ff"}}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].randomColor).toBe(!0)}),a("compiles point, box, line, ring, and sphere-surface spawn zones",()=>{const e=[["positionPoint",{position:Z([1,2,3])},0],["positionSphere",{position:Z([1,2,3]),radius:N(2),surface:{type:y.Boolean,value:!0}},1],["positionBox",{position:Z([1,2,3]),dimensions:Z([4,5,6])},2],["positionLine",{a:Z([1,2,3]),b:Z([4,5,6])},3],["positionRing",{position:Z([1,2,3]),rotation:Z([10,20,30]),radius:N(4),thickness:N(.5),angle:N(180)},4]];for(const[a,i,n]of e){const e=Q(`zone-${a}`);e.initializers[1]={id:`zone-${a}`,type:a,enabled:!0,params:i};const s=g(K(e),[new o.Vector3]);t(s.compatibility.compatible).toBe(!0),t(s.emitters[0].spawnZoneType).toBe(n)}const a=V(16);t(a).toContain("fn rotateSpawnPoint"),t(a).toContain("if (zoneType == 4u)")}),a("preserves legacy per-update sprite rotation behavior",()=>{const e=Q("sprite-rotate");e.behaviours.push({id:"rotate-add",type:"rotateAdd",enabled:!0,params:{rotation:Z([0,0,200])}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].spriteRotationStep).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const i=V(16);t(i).toContain("var rotation = particle.color.w;"),t(i).toContain("rotation += emitter.rotationInitialSpriteStep.w * frame.padding1"),e.output=Y();const n=g(K(e),[new o.Vector3]);t(n.compatibility.compatible).toBe(!0),t(n.emitters[0].meshRotationStep.z).toBeCloseTo(o.MathUtils.degToRad(o.MathUtils.degToRad(200)));const s=V(16,!0);t(s).toContain("rotationEuler: vec4<f32>"),t(s).toContain("fn quaternionFromEulerXYZ"),t(s).toContain("emitter.rotationStep.xyz * frame.padding1"),e.initializers.push({id:"random-mesh-rotation",type:"randomRotation",enabled:!0,params:{axis:{type:y.String,value:"Y"}}});const r=g(K(e),[new o.Vector3]);t(r.compatibility.compatible).toBe(!0),t(r.emitters[0].randomMeshRotationAxis).toBe(3),t(s).toContain("randomRotationAxis == 1u"),t(s).toContain("randomRotationAxis == 4u")}),a("compiles radial velocity without per-particle auxiliary storage",()=>{const e=Q("radial-velocity");e.initializers.push({id:"radial",type:"radialVelocity",enabled:!0,params:{speed:N(4),direction:Z([0,1,0]),spread:N(30)}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].hasRadialVelocity).toBe(!0),t(a.emitters[0].radialVelocitySpeed).toBe(4),t(a.emitters[0].radialVelocitySpread).toBeCloseTo(Math.PI/6);const i=V(16);t(i).toContain("fn radialVelocity"),t(i).toContain("emitter.radialMetadata.y > 0.5")}),a("compiles one attraction or repulsion field per emitter",()=>{const e=Q("attraction");e.behaviours.push({id:"attract",type:"attraction",enabled:!0,params:{target:Z([1,2,3]),force:N(4),radius:N(5)}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].attractionTarget.toArray()).toEqual([1,2,3]),t(a.emitters[0].attractionForce).toBe(4),e.behaviours[0].type="repulsion";const i=g(K(e),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].attractionForce).toBe(-4);const n=V(16);t(n).toContain("attractionDistanceSq < attractionRadiusSq")}),a("compiles mesh alignment modes into spawn-only parameters",()=>{const e=Q("aligned-mesh",Y());e.initializers.push({id:"align",type:"alignRotation",enabled:!0,params:{axis:{type:y.String,value:"+X"},mode:{type:y.String,value:"AlongVelocity"},offset:Z([10,20,30]),center:Z([1,2,3])}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].alignRotationAxis.toArray()).toEqual([1,0,0]),t(a.emitters[0].alignRotationMode).toBe(3),t(a.emitters[0].alignRotationOffset.x).toBeCloseTo(o.MathUtils.degToRad(10));const i=V(16,!0);t(i).toContain("fn quaternionFromUnitVectors"),t(i).toContain("alignRotationMode == 3u")}),a("groups authored render classes deterministically",()=>{const e=g(K(Q("a"),Q("b"),Q("c",X({blendingMode:"normal"}))),[new o.Vector3]);t(e.compatibility.compatible).toBe(!0),t(e.renderClasses).toHaveLength(2),t(e.emitters.map(e=>e.renderClass)).toEqual([0,0,1])}),a("can lay out emitter allocations contiguously per dynamic effect instance",()=>{const e=K(Q("first"),Q("second",X({blendingMode:"normal"}))),a=[new o.Vector3,new o.Vector3(1,0,0)],i=g(e,a),n=g(e,a,"instance-major");t(i.emitters.map(e=>e.renderClass)).toEqual([0,0,1,1]),t(n.emitters.map(e=>e.renderClass)).toEqual([0,1,0,1])}),a("reports sparse active-page simulation capacity",async()=>{const e=await v({asset:K(Q("paged")),instancePositions:[new o.Vector3,new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:2,assets:{getTexture:async()=>new o.Texture}});e.runtime.setActivePages([0,2]),t(e.runtime.getStats()).toMatchObject({capacity:6,activePageCount:2,simulatedCapacity:4})}),a("compacts active page padding out of the simulation schedule",async()=>{const e=await j.create({asset:K(Q("tiny-paged-effect")),maximumInstances:2,particlePageSize:256,assets:{getTexture:async()=>new o.Texture}});e.spawn(new o.Vector3),e.flushActivePages(),t(e.runtime.getStats()).toMatchObject({capacity:512,activePageCount:1,simulatedCapacity:2});const a=V(256);t(a).toContain("let particleIndex = activeParticleIndices[id.x]"),t(a).not.toContain("id.x % pageSize"),e.dispose()}),a("sizes alive indices by logical particles instead of render-class count",async()=>{const e=await v({asset:K(Q("additive"),Q("normal",X({blendingMode:"normal"}))),instancePositions:[new o.Vector3,new o.Vector3],instanceLayout:"instance-major",particlePageSize:4,assets:{getTexture:async()=>new o.Texture}});t(e.runtime.getStats()).toMatchObject({capacity:8,renderClassCount:2,aliveIndexCapacity:8,aliveIndexBytes:32});const a=V(4);t(a).toContain("aliveIndices[indirect[renderClass].firstInstance + outputIndex]"),t(a).not.toContain("renderClass * u32(frame.capacity)")}),a("allocates and recycles multi-page dynamic effect instances",async()=>{const e=Q("paged-instance");e.position=[2,0,0];const a=await j.create({asset:K(e),maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),i=a.spawn(new o.Vector3);t(a.runtime.emitters[0].position.x).toBe(2),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),a.update(.1),t(a.scheduler.getInstance(i.handle)).not.toBeNull(),t(a.play(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!1),t(a.setTimescale(i,2)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(2),t(a.stop(i)).toBe(!0),t(a.runtime.emitters[0].stopTime).toBeGreaterThanOrEqual(0),t(a.runtime.emitters[0].expireWithin).toBe(-1),t(i.pages).toHaveLength(2),a.flushActivePages(),t(a.runtime.getStats()).toMatchObject({activePageCount:2,simulatedCapacity:2}),t(a.destroy(i)).toBe(!0);const n=a.spawn(new o.Vector3(1,0,0));t(new Set(n.pages)).toEqual(new Set(i.pages)),t(n.handle.generation).not.toBe(i.handle.generation),a.dispose()}),a("caps GPU particle drain time for expire and kill lifecycle behavior",async()=>{const e=await j.create({asset:K(Q("expiring")),maximumInstances:2,assets:{getTexture:async()=>new o.Texture}}),a=e.spawn(new o.Vector3);t(e.stop(a,.5)).toBe(!0),t(e.runtime.emitters[0].expireWithin).toBe(.5);for(let t=0;t<4;t++)e.update(.1);t(e.scheduler.getInstance(a.handle)).not.toBeNull(),e.update(.1),t(e.scheduler.getInstance(a.handle)).toBeNull();const i=e.spawn(new o.Vector3);t(e.stop(i,0)).toBe(!0),e.update(.001),t(e.scheduler.getInstance(i.handle)).toBeNull(),e.dispose()}),a("rejects a template pool before allocation when its storage budget is exceeded",async()=>{const e=await j.create({asset:K(Q("over-budget")),maximumInstances:128,particlePageSize:1,maximumStorageBytes:1,assets:{getTexture:async()=>new o.Texture}});t(e).toBeNull()}),a("compiles delayed and finite continuous emission timing",()=>{const e=Q("delayed");e.rate={type:"once",count:2,delay:.25};const a=Q("finite");a.rate={type:"continuous",count:2,time:.1,delay:0,duration:1};const i=g(K(e,a),[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.emitters[0].rate).toMatchObject({type:"once",delay:.25}),t(i.emitters[1].rate).toMatchObject({type:"continuous",delay:0,duration:1})}),a("treats zero continuous duration as unlimited like the CPU rate",()=>{const e=Q("unlimited");e.rate={type:"continuous",count:2,time:.1,delay:0,duration:0};const a=g(K(e),[new o.Vector3]);t(a.compatibility.issues.map(e=>e.code)).not.toContain("rate-duration")}),a("compiles bound lifetime metadata but falls back for unsupported input targets",()=>{const e=K(Q("input-test"));e.vfx.inputs=[{id:"speed",name:"Speed",type:"number",defaultValue:1}],t(g(e,[new o.Vector3]).compatibility.compatible).toBe(!0),e.vfx.emitters[0].initializers[0].params.duration.binding={type:"vfxInput",inputId:"speed"};const a=g(e,[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.inputBindings).toEqual([t.objectContaining({inputId:"speed",emitterIndex:0,target:"initializer:lifetime:duration"})]),e.vfx.emitters[0].initializers.push({id:"bound-velocity",type:"velocity",enabled:!0,params:{velocity:{type:y.Vector3,value:[0,1,0],binding:{type:"vfxInput",inputId:"speed"}}}}),t(g(e,[new o.Vector3]).compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding")}),a("falls back for a bound continuous lifetime until particle capacity can grow",()=>{const e=Q("continuous-bound-life");e.rate={type:"continuous",count:1,time:.1,delay:0,duration:0},e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"};const a=K(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:2}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues.map(e=>e.code)).toContain("vfx-input-binding"),t(i.inputBindings).toEqual([])}),a("caches compiled templates while keeping instance emitter state independent",async()=>{const e=K(Q("cached-template")),a=new z;let i=0;const n={assets:{getTexture:async()=>(i+=1,new o.Texture)}},s=await a.resolve(e,n),r=await a.resolve(e,n);t(r.template).toBe(s.template),t(i).toBe(1),t(a.getStats()).toMatchObject({builds:1,hits:1});const l=A(s.template,[new o.Vector3(1,0,0),new o.Vector3(2,0,0)]);t(l[0].position).not.toBe(l[1].position),l[0].position.x=100,t(l[1].position.x).toBe(2),t(s.template.emitters[0].position.x).toBe(0),a.invalidate(e),await a.resolve(e,n),t(a.getStats()).toMatchObject({builds:2,hits:1,invalidations:1}),await a.resolve(e,{...n,revision:2}),t(a.getStats()).toMatchObject({builds:3,hits:1,invalidations:2})}),a("applies a bound lifetime only to the addressed scheduled instance",async()=>{const e=Q("bound-life");e.initializers[0].params.duration.binding={type:"vfxInput",inputId:"life"},e.initializers[1].params.radius.binding={type:"vfxInput",inputId:"radius"},e.initializers.push({id:"velocity",type:"velocity",enabled:!0,params:{velocity:{type:y.Vector3,value:[0,0,0],binding:{type:"vfxInput",inputId:"velocity"}}}});const a=K(e);a.vfx.inputs=[{id:"life",name:"Life",type:"number",defaultValue:1},{id:"radius",name:"Radius",type:"number",defaultValue:1},{id:"velocity",name:"Velocity",type:"vec3",defaultValue:[0,0,0]}];const i=await j.create({asset:a,maximumInstances:2,particlePageSize:1,assets:{getTexture:async()=>new o.Texture}}),n=i.spawn(new o.Vector3),s=i.spawn(new o.Vector3);t(i.setInput(n,"life",4)).toBe(!0),t(i.runtime.emitters[0].life).toBe(4),t(i.runtime.emitters[1].life).toBe(2),t(i.setInput(n,"radius",3)).toBe(!0),t(i.runtime.emitters[0].positionRadius).toBe(3),t(i.setInput(n,"velocity",new o.Vector3(1,2,3))).toBe(!0),t(i.runtime.emitters[0].velocityMin.toArray()).toEqual([1,2,3]),t(i.setInput(s,"missing",5)).toBe(!1),i.dispose()}),a("schedules different templates in one arena and merges shared render classes",async()=>{const e=X(),a=await q.create({templates:[{asset:K(Q("world-a",e)),maximumInstances:2},{asset:K(Q("world-b",{...e})),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},templateCache:new z,renderClassCache:new R,particlePageSize:1}),i=a.spawn(0,new o.Vector3),n=a.spawn(1,new o.Vector3(1,0,0));a.flushActivePages(),t(a.templateCount).toBe(2),t(a.runtime.getStats()).toMatchObject({renderClassCount:1,activePageCount:4}),t(a.scheduler.getStats()).toMatchObject({activeInstances:2,arena:{usedPages:4}}),t(a.destroy(i)).toBe(!0),t(a.destroy(n)).toBe(!0),a.flushActivePages(),t(a.runtime.hasActiveParticles).toBe(!1),t(a.runtime.getStats()).toMatchObject({aliveParticles:0,activePageCount:0,simulatedCapacity:0}),a.dispose()}),a("routes per-instance lifecycle changes through a shared multi-template world",async()=>{const e=Q("world-lifecycle");e.position=[2,0,0];const a=await q.create({templates:[{asset:K(e),maximumInstances:1},{asset:K(Q("world-neighbour")),maximumInstances:1}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1}),i=a.spawn(0,new o.Vector3(3,0,0));t(a.runtime.emitters[0].position.x).toBe(5),t(a.setPosition(i,new o.Vector3(7,0,0))).toBe(!0),t(a.runtime.emitters[0].position.x).toBe(9),t(a.pause(i)).toBe(!0),t(a.runtime.emitters[0].paused).toBe(!0),t(a.play(i)).toBe(!0),t(a.setTimescale(i,.5)).toBe(!0),t(a.runtime.emitters[0].timescale).toBe(.5),t(a.stop(i,0)).toBe(!0),a.update(.001),t(a.has(i)).toBe(!1),t(a.setPosition(i,new o.Vector3)).toBe(!1),a.dispose()}),a("rejects a shared world before runtime allocation when its storage budget is exceeded",async()=>{const e=await q.create({templates:[{asset:K(Q("world-over-budget")),maximumInstances:2}],assets:{getTexture:async()=>new o.Texture},particlePageSize:1,maximumStorageBytes:1});t(e).toBeNull()}),a("falls back to CPU for semantic mismatches",()=>{const e=Q("soft-local",X({softness:1,space:"local",lockY:!0}));e.behaviours.push({id:"animation",type:"animation",enabled:!0,params:{}});const a=g(K(e),[new o.Vector3]),i=C(a.compatibility);t(a.compatibility.compatible).toBe(!1),t(a.compatibility.issues.map(e=>e.code)).toEqual(t.arrayContaining(["behaviour:animation"])),t(i.backend).toBe("cpu"),t(C(a.compatibility,"gpu").backend).toBe("gpu")}),a("intentionally selects CPU simulation for physics-raycast world collision",()=>{const e=Q("physics-collision");e.behaviours.push({id:"collision",type:"worldCollision",enabled:!0,params:{method:{type:y.String,value:f.PhysicsRaycast}}});const a=K(e),i=g(a,[new o.Vector3]);t(w(a)).toBe(!0),t(i.compatibility.compatible).toBe(!1),t(i.compatibility.issues).toContainEqual(t.objectContaining({severity:"requirement",code:"physics-raycast-collision"})),t(C(i.compatibility).backend).toBe("cpu"),t(C(i.compatibility,"gpu").backend).toBe("cpu")}),a("compiles vortex with pay-for-play previous-velocity state",()=>{const e=Q("vortex");e.behaviours.push({id:"vortex",type:"vortex",enabled:!0,params:{axis:Z([0,5,0]),amount:N(1.25)}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].vortexAxis.toArray()).toEqual([0,5,0]),t(a.emitters[0].vortexAmount).toBe(1.25),t(V(16,!1,!1)).not.toContain("vortexVelocities");const i=V(16,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("behaviourVelocity -= optionalStates[0u + particleIndex].xyz")}),a("compiles the remaining initializer and motion behaviours",()=>{const e=Q("remaining-motion");e.initializers.push({id:"mass",type:"mass",enabled:!0,params:{min:N(2)}}),e.behaviours.push({id:"drift",type:"randomDrift",enabled:!0,params:{drift:Z([1,2,3]),delay:N(.25),ease:{type:y.String,value:"OutQuad"}}},{id:"follow",type:"followParent",enabled:!0,params:{speed:N(4)}},{id:"move",type:"moveTo",enabled:!0,params:{target:Z([5,6,7]),ease:{type:y.String,value:"InQuad"}}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0]).toMatchObject({mass:2,randomDriftDelay:.25,randomDriftEasing:2,followParentSpeed:4,hasFollowParent:!0,moveToEasing:1,hasMoveTo:!0}),t(a.emitters[0].randomDrift.toArray()).toEqual([1,2,3]),t(a.emitters[0].moveToTarget.toArray()).toEqual([5,6,7])}),a("compiles authored easing independently for visual behavior channels",()=>{const e=Q("easing");e.behaviours.push({id:"scale",type:"scale",enabled:!0,params:{a:N(0),b:N(1),ease:{type:y.String,value:"OutBack"}}},{id:"color",type:"changeColor",enabled:!0,params:{a:{type:y.Color,value:"#ff0000"},b:{type:y.Color,value:"#0000ff"},ease:{type:y.String,value:"InCubic"}}},{id:"opacity",type:"changeOpacity",enabled:!0,params:{a:N(0),b:N(1),ease:{type:y.String,value:"BumpSharp"}}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].curveEasing.toArray()).toEqual([18,4,20,0]);const i=V(16);t(i).toContain("fn applyEasing"),t(i).toContain("mix(emitter.colorStart.rgb, emitter.colorEnd.rgb, colorProgress)"),t(i).toContain("mix(emitter.scales.xy, emitter.scales.zw, scaleProgress)")}),a("compiles disperse into optional shared particle state",()=>{const e=Q("disperse");e.behaviours.push({id:"disperse",type:"disperse",enabled:!0,params:{distance:N(3),axis:{type:y.String,value:"XZ"},ease:{type:y.String,value:"InOutSine"}}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].disperseDistance).toBe(3),t(a.emitters[0].dispersePlane).toBe(2),t(a.emitters[0].disperseEasing).toBe(12),t(V(16)).not.toContain("disperseStates");const i=V(16,!1,!1,!0);t(i).toContain("@group(0) @binding(8)"),t(i).toContain("optionalStates[0u + particleIndex].w = disperseProgress")}),a("consolidates every optional simulation feature into the eighth storage binding",()=>{const e=V(16,!0,!0,!0,!0,!1,!1,48,64),a=[...e.matchAll(/@group\(0\) @binding\((\d+)\) var<storage/g)].map(e=>Number(e[1]));t(a).toEqual([1,2,3,4,5,6,7,8]),t(e).toContain("optionalStates[48u + particleIndex]"),t(e).toContain("optionalStates[64u + particleIndex]"),t(e).not.toMatch(/@binding\((9|10|11)\)/)}),a("lays out optional state regions without overlap",()=>{t(x({capacity:16,particleTransforms:!0,vortex:!0,disperse:!0,trailHistoryEntries:20})).toEqual({vortexOffset:48,disperseOffset:64,collisionEventOffset:80,trailOffset:80,meshSamplerOffset:100,entries:100,bytes:1600})}),a("materializes decal outputs as shared unit-box mesh render classes",async()=>{const e=Q("decal");e.output={type:"decal",space:"world",colorMap:"",alphaMap:"",color:"#ffffff",unlit:!0,intensity:1,blendingMode:"normal",bloom:!1};const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const n=new i({color:r(s(1),1)}),l=await b(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>n);t(l.renderClasses[0].meshVertices?.length).toBe(288),t(l.renderClasses[0].material).toBe(n)}),a("materializes generated shape outputs through the shared mesh path",async()=>{const e=Q("shape");e.output={type:"shape",shape:"box",params:{},material:"shape-material",materialSource:"material",space:"world"};const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const n=new i({color:r(s(1),1)}),l=await b(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>n);t(l.renderClasses[0].meshVertices?.length).toBe(288)}),a("forwards the physical particle index to fragment-stage model-matrix graphs",()=>{const e=T(new i({color:r(s(1),1)})),a=M(e,!0);t(a).toContain("color: vec4<f32>, instanceIndex: u32"),t(a).toContain("gpuParticle.color, particleIndex)"),t(a).toContain("@interpolate(flat) instanceIndex: u32"),t(a).toContain("output.instanceIndex = particleIndex")}),a("compiles soft default sprites with a pay-for-play scene-depth requirement",async()=>{const e=Q("soft-sprite",X({softness:2})),a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0);const i=(await b(a.renderClasses,{getTexture:async()=>new o.Texture})).renderClasses[0].material,n=T(i);t(Object.values(n.sceneTextures)).toContain("sceneDepth")}),a("disables depth writes only for transparent custom GPU sprite materials",async()=>{const e=Q("graph-sprite",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"graph"}})),a=g(K(e),[new o.Vector3]),n=new i({color:r(s(1),.5),transparent:!0}),l=await b(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>n);t(l.renderClasses[0].material).toBe(n),t(n.depthWrite).toBe(!1),t(T(n).renderState.depthWrite).toBe(!1);const c=new i({color:r(s(1),1)}),p=await b(a.renderClasses,{getTexture:async()=>new o.Texture},async()=>c);t(p.renderClasses[0].material).toBe(c),t(c.depthWrite).toBe(!0),t(T(c).renderState.depthWrite).toBe(!0)}),a("supports local-space sprite render classes through the shared emitter transform table",async()=>{const e=Q("local-sprite",X({space:"local"})),a=g(K(e),[new o.Vector3(4,5,6)]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].localSpace).toBe(!0),t(a.emitters[0].position.toArray()).toEqual([0,0,0]);const i=await b(a.renderClasses,{getTexture:async()=>new o.Texture});t(i.renderClasses[0].localSpace).toBe(!0)}),a("falls back before rendering when an authored graph cannot be resolved",async()=>{const e=Q("graph",X({materialSource:"shaderGraph",shaderGraph:{source:"asset",assetId:"missing-graph"}})),a=await v({asset:K(e),instancePositions:[new o.Vector3],assets:{getTexture:async()=>new o.Texture}});t(a.backend).toBe("cpu"),t(a.runtime).toBeNull(),t(a.reasons[0]).toContain("GPU material compilation failed")}),a("compiles default flipbook, texture, UV, and particle semantics to WGSL",async()=>{const e=Q("flipbook",X({flipbook:{enabled:!0,rows:4,columns:4,fps:12,mode:"loop"}})),a=g(K(e),[new o.Vector3]),i=await b(a.renderClasses,{getTexture:async()=>new o.Texture}),n=T(i.renderClasses[0].material);t(n.fragmentMain).toContain("textureSample"),t(`${n.vertexMain}\n${n.fragmentMain}`).toContain("gpuParticle.positionAge.w"),t(n.varyings.length).toBeGreaterThan(0)}),a("supports component scale-over-life for sprites",()=>{const e=Q("stretched-smoke");e.behaviours.push({id:"stretch",type:"scaleComponents",enabled:!0,params:{a:Z([.25,1,1]),b:Z([2,.5,1]),ease:{type:22,value:"Linear"}}});const a=g(K(e),[new o.Vector3]);t(a.compatibility.compatible).toBe(!0),t(a.emitters[0].meshScaleStart.toArray()).toEqual([.25,1,1]),t(a.emitters[0].meshScaleEnd.toArray()).toEqual([2,.5,1])}),a("compiles authored scale and rotation input bindings",()=>{const e=Q("input-driven",Y());e.initializers.push({id:"scale",type:"scale",enabled:!0,params:{scale:{...N(1),binding:{type:"vfxInput",inputId:"scale-input"}}}},{id:"rotation",type:"rotation",enabled:!0,params:{rotation:{...Z([0,-100,0]),binding:{type:"vfxInput",inputId:"rotation-input"}}}}),e.behaviours.push({id:"rotate",type:"rotate",enabled:!0,params:{rotation:{...Z([0,-30,0]),binding:{type:"vfxInput",inputId:"step-input"}}}});const a=K(e);a.vfx.inputs=[{id:"scale-input",name:"Scale",type:"number",defaultValue:1},{id:"rotation-input",name:"Rotation",type:"vec3",defaultValue:[0,-100,0]},{id:"step-input",name:"Rotation step",type:"vec3",defaultValue:[0,-30,0]}];const i=g(a,[new o.Vector3]);t(i.compatibility.compatible).toBe(!0),t(i.inputBindings.map(e=>e.target)).toEqual(["initializer:scale:scale","initializer:rotation:rotation","behaviour:rotate:rotation"])})}),a("shares default sprite shader source across numeric settings without merging material bindings",async()=>{const e=X({softness:1,flipbook:{enabled:!0,columns:4,rows:4,fps:12,mode:"loop"}}),a=X({softness:5,flipbook:{enabled:!0,columns:8,rows:8,fps:64,mode:"loop"}}),i=g(K(Q("first",e),Q("second",a)),[new o.Vector3]);t(i.renderClasses).toHaveLength(2);const n=new o.Texture,s=await b(i.renderClasses,{getTexture:async()=>n}),r=s.renderClasses[0].material,l=s.renderClasses[1].material;t(r.uniforms.vfxSoftness.value).toBe(.2),t(l.uniforms.vfxSoftness.value).toBe(1),t(r.uniforms.vfxFlipbookColumns.value).toBe(4),t(l.uniforms.vfxFlipbookColumns.value).toBe(8),t(r.uniforms.vfxFlipbookFps.value).toBe(12),t(l.uniforms.vfxFlipbookFps.value).toBe(64),t(M(T(r),!0)).toBe(M(T(l),!0));const c=g(K(Q("first",e),Q("second",{...a,softness:1})),[new o.Vector3]);t(c.renderClasses).toHaveLength(2)});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|
package/dist/test/input.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{expect as e,test as a,vi as t}from"vitest";import s from"typedi";import{ActionInput as n,ButtonInput as o,ToggleInput as d}from"../gameplay/input/input.js";t.mock("../gameplay/services/render.js",()=>({ViewController:class{}}));import{InputService as p}from"../gameplay/input/input-service.js";import{GamepadButtonBind as l,Keybind as u}from"../gameplay/input/keybind.js";import{ViewController as i}from"../gameplay/services/render.js";function r(){const e=Object.create(p.prototype);return Object.assign(e,{view:{paused:!1},inputs:new Map,deltaCallbacks:new Map,toggleCallbacks:new Map,keybinds:new Map,gamepadButtonBinds:new Map,gamepadAxisBinds:new Map,activeToggleCallbacks:[],prevGamepadButtonStates:new Map,suppressedGamepadButtons:new Map,sampledGamepadButtonStates:new Map,pendingGamepadButtonTransitions:new Map,frameGamepadButtonTransitions:new Map,lastActiveDevice:"kbm",onDeviceChanged:{next:()=>{}}}),e}a("button input uses hold mode by default",()=>{const a=new o,t=[];a.onStart(()=>t.push(a.activated)),a.onEnd(()=>t.push(a.activated)),a.toggle(!0),e(a.activated).toBe(!0),a.toggle(!0),e(t).toEqual([!0]),a.toggle(!1),e(a.activated).toBe(!1),e(t).toEqual([!0,!1])}),a("removed start listeners do not run on subsequent presses",()=>{const a=new o,s=t.fn(),n=t.fn();a.onStart(s),a.onStart(n),a.removeOnStart(s),a.removeOnStart(s),a.toggle(!0),e(s).not.toHaveBeenCalled(),e(n).toHaveBeenCalledTimes(1)}),a("button input can switch to toggle mode at runtime",()=>{const a=new o,t=[];a.onStart(()=>t.push(a.activated)),a.onEnd(()=>t.push(a.activated)),a.mode="toggle",a.toggle(!0),e(a.activated).toBe(!1),a.toggle(!1),e(a.activated).toBe(!0),a.toggle(!1),e(a.activated).toBe(!1),e(t).toEqual([!0,!1])}),a("action and toggle inputs remain compatibility presets",()=>{const a=new n;a.toggle(!0),e(a.activated).toBe(!0),a.toggle(!1),e(a.activated).toBe(!1);const t=new d;t.toggle(!0),e(t.activated).toBe(!1),t.toggle(!1),e(t.activated).toBe(!0)}),a("held gamepad buttons can be suppressed until released",()=>{const a={pressed:!0,value:1},t={connected:!0,mapping:"standard",buttons:[a],axes:[]},s=r(),n=[];s.gamepadButtonBinds.set("jump",[new l(0)]),s.toggleCallbacks.set("jump",e=>n.push(e)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[t]}),s.suppressHeldGamepadButtonsUntilRelease(),s.update(0),a.pressed=!1,a.value=0,s.update(0),a.pressed=!0,a.value=1,s.update(0),e(n).toEqual([!0])}),a("mouse button gameplay input requires pointer lock",()=>{let a=null;Object.defineProperty(document,"pointerLockElement",{configurable:!0,get:()=>a}),s.set(i,{paused:!1,htmlElement:document.body});const t=new p,n=[];t.keybinds.set("attack",[new u("MouseLeft")]),t.toggleCallbacks.set("attack",e=>n.push(e)),t.onKeyDown({button:0}),a=document.body,t.onKeyDown({button:0}),e(n).toEqual([!0])}),a("suppression consumes the release for all actions sharing a gamepad button",()=>{const a={pressed:!0,value:1},s={mapping:"standard",buttons:[a],axes:[]},n=r(),o=t.fn(),p=new d;n.setGamepadButtonBind("jump",new l(0)),n.setGamepadButtonBind("crouch",new l(0)),n.bindToggle("jump",o),n.bindToggle("crouch",p.toggle),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.suppressHeldGamepadButtonsUntilRelease(),n.update(0),Object.assign(a,{pressed:!1,value:0}),n.update(0),e(o).not.toHaveBeenCalled(),e(p.activated).toBe(!1),Object.assign(a,{pressed:!0,value:1}),n.update(0),Object.assign(a,{pressed:!1,value:0}),n.update(0),e(o.mock.calls).toEqual([[!0],[!1]]),e(p.activated).toBe(!0)}),a("suppression clears when an unbound gamepad button is released",()=>{const a={pressed:!0,value:1},s={mapping:"standard",buttons:[a],axes:[]},n=r(),o=t.fn();Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.suppressHeldGamepadButtonsUntilRelease(),Object.assign(a,{pressed:!1,value:0}),n.update(0),n.setGamepadButtonBind("jump",new l(0)),n.bindToggle("jump",o),Object.assign(a,{pressed:!0,value:1}),n.update(0),e(o).toHaveBeenCalledWith(!0)}),a("a jump tap between rendered frames remains active for one gameplay update",()=>{const a={pressed:!1,value:0},s={mapping:"standard",buttons:[a],axes:[]},o=r(),d=new n,p=t.fn();d.onStart(p),o.bindToggle("jump",d.toggle),o.setGamepadButtonBind("jump",new l(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),o.update(1/30),Object.assign(a,{pressed:!0,value:1}),o.sampleGamepadButtons(!1),Object.assign(a,{pressed:!1,value:0}),o.sampleGamepadButtons(!1),o.update(1/30),e(p).toHaveBeenCalledOnce(),e(d.activated).toBe(!0),o.update(1/30),e(d.activated).toBe(!1)}),a("an attack release and repress between rendered frames emits another start",()=>{const a={pressed:!0,value:1},s={mapping:"standard",buttons:[a],axes:[]},o=r(),d=new n,p=t.fn();d.onStart(p),o.bindToggle("attack",d.toggle),o.setGamepadButtonBind("attack",new l(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),o.update(1/30),Object.assign(a,{pressed:!1,value:0}),o.sampleGamepadButtons(!1),Object.assign(a,{pressed:!0,value:1}),o.sampleGamepadButtons(!1),o.update(1/30),e(p).toHaveBeenCalledTimes(2),e(d.activated).toBe(!0)}),a("paused sampling discards taps and releases an action that was already held",()=>{const a={pressed:!0,value:1},s={mapping:"standard",buttons:[a],axes:[]},o=r(),d=new n,p=t.fn();d.onStart(p),o.bindToggle("attack",d.toggle),o.setGamepadButtonBind("attack",new l(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),o.update(0),o.sampleGamepadButtons(!0),o.update(0),e(d.activated).toBe(!1),e(p).toHaveBeenCalledOnce(),Object.assign(a,{pressed:!1,value:0}),o.sampleGamepadButtons(!0),Object.assign(a,{pressed:!0,value:1}),o.sampleGamepadButtons(!0),Object.assign(a,{pressed:!1,value:0}),o.sampleGamepadButtons(!0),o.update(0),e(p).toHaveBeenCalledOnce(),Object.assign(a,{pressed:!0,value:1}),o.update(0),e(p).toHaveBeenCalledTimes(2)}),a("modal suppression discards buffered taps and clears on a sampled release",()=>{const a={pressed:!0,value:1},s={mapping:"standard",buttons:[a],axes:[]},n=r(),o=t.fn();n.bindToggle("jump",o),n.setGamepadButtonBind("jump",new l(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.sampleGamepadButtons(!1),Object.assign(a,{pressed:!1,value:0}),n.sampleGamepadButtons(!1),n.suppressHeldGamepadButtonsUntilRelease(),n.update(0),e(o).not.toHaveBeenCalled(),Object.assign(a,{pressed:!0,value:1}),n.suppressHeldGamepadButtonsUntilRelease(),Object.assign(a,{pressed:!1,value:0}),n.sampleGamepadButtons(!1),Object.assign(a,{pressed:!0,value:1}),n.sampleGamepadButtons(!1),n.update(0),e(o.mock.calls).toEqual([[!0]])}),a("a callback can suppress other buffered buttons without leaving stale delivered states",()=>{const a=[{pressed:!1,value:0},{pressed:!1,value:0}],s={mapping:"standard",buttons:a,axes:[]},n=r(),o=t.fn();n.bindToggle("modal",e=>{e&&n.suppressHeldGamepadButtonsUntilRelease()}),n.bindToggle("jump",o),n.setGamepadButtonBind("modal",new l(0)),n.setGamepadButtonBind("jump",new l(1)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.update(0),Object.assign(a[0],{pressed:!0,value:1}),Object.assign(a[1],{pressed:!0,value:1}),n.sampleGamepadButtons(!1),Object.assign(a[1],{pressed:!1,value:0}),n.sampleGamepadButtons(!1),n.update(0),e(o).not.toHaveBeenCalled(),Object.assign(a[1],{pressed:!0,value:1}),n.update(0),e(o.mock.calls).toEqual([[!0]])});/*
|
|
1
|
+
import{expect as e,test as t,vi as a}from"vitest";import s from"typedi";import{BehaviorSubject as n}from"rxjs";import{ActionInput as o,ButtonInput as d,ToggleInput as p}from"../gameplay/input/input.js";a.mock("../gameplay/services/render.js",()=>({ViewController:class{}}));import{InputService as u}from"../gameplay/input/input-service.js";import{GamepadAxisBind as r,GamepadButtonBind as i,Keybind as l}from"../gameplay/input/keybind.js";import{ViewController as c}from"../gameplay/services/render.js";function m(){const e={paused:!1,pausedChanged:new n(!1),htmlElement:document.body};s.set(c,e);const t=new u,o=[0,1].map(e=>({index:e,connected:!0,mapping:"standard",buttons:[{pressed:!1,value:0}],axes:[0],vibrationActuator:{playEffect:a.fn().mockResolvedValue("complete"),reset:a.fn().mockResolvedValue("complete")}}));return Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>o}),a.spyOn(document,"hasFocus").mockReturnValue(!0),t.setGamepadButtonBind("attack",new i(0)),t.setGamepadAxisBind("move",new r(0)),t.bindToggle("attack",()=>{}),t.bindDelta("move",()=>{}),{input:t,pads:o,view:e}}function g(){const e=Object.create(u.prototype);return Object.assign(e,{view:{paused:!1},inputs:new Map,deltaCallbacks:new Map,toggleCallbacks:new Map,keybinds:new Map,gamepadButtonBinds:new Map,gamepadAxisBinds:new Map,activeToggleCallbacks:[],prevGamepadButtonStates:new Map,suppressedGamepadButtons:new Map,sampledGamepadButtonStates:new Map,pendingGamepadButtonTransitions:new Map,frameGamepadButtonTransitions:new Map,lastActiveDevice:"kbm",onDeviceChanged:{next:()=>{}}}),e}t("vibration follows the controller providing input and stops on keyboard input",()=>{const{input:t,pads:s}=m();e(t.vibrate(55)).toBe(!1),s[1].buttons[0]={pressed:!0,value:1},t.update(0),e(t.vibrate(55,2,-1)).toBe(!0),e(s[0].vibrationActuator.playEffect).not.toHaveBeenCalled(),e(s[1].vibrationActuator.playEffect).toHaveBeenCalledWith("dual-rumble",{startDelay:0,duration:55,strongMagnitude:1,weakMagnitude:0}),s[0].axes[0]=.7,t.update(0),e(s[1].vibrationActuator.reset).toHaveBeenCalledOnce(),e(t.vibrate(55)).toBe(!0),t.onKeyDown({key:"w"}),e(s[0].vibrationActuator.reset).toHaveBeenCalledOnce(),e(t.vibrate(55)).toBe(!1),a.restoreAllMocks()}),t("vibration stops on pause, focus loss, disconnect and shutdown",()=>{const{input:t,pads:s,view:n}=m();t.start(),s[0].axes[0]=.7,t.update(0),t.vibrate(100),n.paused=!0,n.pausedChanged.next(!0),e(t.vibrate(100)).toBe(!1),n.paused=!1,n.pausedChanged.next(!1),t.vibrate(100),window.dispatchEvent(new Event("blur")),t.vibrate(100);const o=new Event("gamepaddisconnected");Object.defineProperty(o,"gamepad",{value:s[0]}),window.dispatchEvent(o),e(t.vibrate(100)).toBe(!1),t.update(0),t.vibrate(100),t.stop(),e(s[0].vibrationActuator.reset).toHaveBeenCalledTimes(4),e(t.vibrate(100)).toBe(!1),a.restoreAllMocks()}),t("hidden or unfocused pages cannot start vibration",()=>{const{input:t,pads:s}=m();t.start(),s[0].axes[0]=.7,t.update(0),t.vibrate(55),a.spyOn(document,"hidden","get").mockReturnValue(!0),document.dispatchEvent(new Event("visibilitychange")),e(s[0].vibrationActuator.reset).toHaveBeenCalledOnce(),e(t.vibrate(55)).toBe(!1),a.spyOn(document,"hidden","get").mockReturnValue(!1),a.spyOn(document,"hasFocus").mockReturnValue(!1),e(t.vibrate(55)).toBe(!1),t.stop(),a.restoreAllMocks()}),t("missing actuators, invalid pulses and rejected hardware calls are harmless",async()=>{const{input:t,pads:s}=m();s[0].axes[0]=.7,t.update(0),e(t.vibrate(NaN)).toBe(!1),e(t.vibrate(0)).toBe(!1),e(t.vibrate(55,1/0)).toBe(!1),s[0].vibrationActuator.playEffect.mockRejectedValue(new Error("unsupported")),t.vibrate(55),await Promise.resolve(),s[0].vibrationActuator.playEffect.mockImplementation(()=>{throw new Error("disconnected")}),e(t.vibrate(55)).toBe(!1),s[0].vibrationActuator=void 0,e(t.vibrate(55)).toBe(!1),s[0].connected=!1,e(t.vibrate(55)).toBe(!1),a.restoreAllMocks()}),t("button input uses hold mode by default",()=>{const t=new d,a=[];t.onStart(()=>a.push(t.activated)),t.onEnd(()=>a.push(t.activated)),t.toggle(!0),e(t.activated).toBe(!0),t.toggle(!0),e(a).toEqual([!0]),t.toggle(!1),e(t.activated).toBe(!1),e(a).toEqual([!0,!1])}),t("removed start listeners do not run on subsequent presses",()=>{const t=new d,s=a.fn(),n=a.fn();t.onStart(s),t.onStart(n),t.removeOnStart(s),t.removeOnStart(s),t.toggle(!0),e(s).not.toHaveBeenCalled(),e(n).toHaveBeenCalledTimes(1)}),t("button input can switch to toggle mode at runtime",()=>{const t=new d,a=[];t.onStart(()=>a.push(t.activated)),t.onEnd(()=>a.push(t.activated)),t.mode="toggle",t.toggle(!0),e(t.activated).toBe(!1),t.toggle(!1),e(t.activated).toBe(!0),t.toggle(!1),e(t.activated).toBe(!1),e(a).toEqual([!0,!1])}),t("action and toggle inputs remain compatibility presets",()=>{const t=new o;t.toggle(!0),e(t.activated).toBe(!0),t.toggle(!1),e(t.activated).toBe(!1);const a=new p;a.toggle(!0),e(a.activated).toBe(!1),a.toggle(!1),e(a.activated).toBe(!0)}),t("held gamepad buttons can be suppressed until released",()=>{const t={pressed:!0,value:1},a={connected:!0,mapping:"standard",buttons:[t],axes:[]},s=g(),n=[];s.gamepadButtonBinds.set("jump",[new i(0)]),s.toggleCallbacks.set("jump",e=>n.push(e)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[a]}),s.suppressHeldGamepadButtonsUntilRelease(),s.update(0),t.pressed=!1,t.value=0,s.update(0),t.pressed=!0,t.value=1,s.update(0),e(n).toEqual([!0])}),t("mouse button gameplay input requires pointer lock",()=>{let t=null;Object.defineProperty(document,"pointerLockElement",{configurable:!0,get:()=>t}),s.set(c,{paused:!1,htmlElement:document.body});const a=new u,n=[];a.keybinds.set("attack",[new l("MouseLeft")]),a.toggleCallbacks.set("attack",e=>n.push(e)),a.onKeyDown({button:0}),t=document.body,a.onKeyDown({button:0}),e(n).toEqual([!0])}),t("suppression consumes the release for all actions sharing a gamepad button",()=>{const t={pressed:!0,value:1},s={mapping:"standard",buttons:[t],axes:[]},n=g(),o=a.fn(),d=new p;n.setGamepadButtonBind("jump",new i(0)),n.setGamepadButtonBind("crouch",new i(0)),n.bindToggle("jump",o),n.bindToggle("crouch",d.toggle),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.suppressHeldGamepadButtonsUntilRelease(),n.update(0),Object.assign(t,{pressed:!1,value:0}),n.update(0),e(o).not.toHaveBeenCalled(),e(d.activated).toBe(!1),Object.assign(t,{pressed:!0,value:1}),n.update(0),Object.assign(t,{pressed:!1,value:0}),n.update(0),e(o.mock.calls).toEqual([[!0],[!1]]),e(d.activated).toBe(!0)}),t("suppression clears when an unbound gamepad button is released",()=>{const t={pressed:!0,value:1},s={mapping:"standard",buttons:[t],axes:[]},n=g(),o=a.fn();Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.suppressHeldGamepadButtonsUntilRelease(),Object.assign(t,{pressed:!1,value:0}),n.update(0),n.setGamepadButtonBind("jump",new i(0)),n.bindToggle("jump",o),Object.assign(t,{pressed:!0,value:1}),n.update(0),e(o).toHaveBeenCalledWith(!0)}),t("a jump tap between rendered frames remains active for one gameplay update",()=>{const t={pressed:!1,value:0},s={mapping:"standard",buttons:[t],axes:[]},n=g(),d=new o,p=a.fn();d.onStart(p),n.bindToggle("jump",d.toggle),n.setGamepadButtonBind("jump",new i(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.update(1/30),Object.assign(t,{pressed:!0,value:1}),n.sampleGamepadButtons(!1),Object.assign(t,{pressed:!1,value:0}),n.sampleGamepadButtons(!1),n.update(1/30),e(p).toHaveBeenCalledOnce(),e(d.activated).toBe(!0),n.update(1/30),e(d.activated).toBe(!1)}),t("an attack release and repress between rendered frames emits another start",()=>{const t={pressed:!0,value:1},s={mapping:"standard",buttons:[t],axes:[]},n=g(),d=new o,p=a.fn();d.onStart(p),n.bindToggle("attack",d.toggle),n.setGamepadButtonBind("attack",new i(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.update(1/30),Object.assign(t,{pressed:!1,value:0}),n.sampleGamepadButtons(!1),Object.assign(t,{pressed:!0,value:1}),n.sampleGamepadButtons(!1),n.update(1/30),e(p).toHaveBeenCalledTimes(2),e(d.activated).toBe(!0)}),t("paused sampling discards taps and releases an action that was already held",()=>{const t={pressed:!0,value:1},s={mapping:"standard",buttons:[t],axes:[]},n=g(),d=new o,p=a.fn();d.onStart(p),n.bindToggle("attack",d.toggle),n.setGamepadButtonBind("attack",new i(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.update(0),n.sampleGamepadButtons(!0),n.update(0),e(d.activated).toBe(!1),e(p).toHaveBeenCalledOnce(),Object.assign(t,{pressed:!1,value:0}),n.sampleGamepadButtons(!0),Object.assign(t,{pressed:!0,value:1}),n.sampleGamepadButtons(!0),Object.assign(t,{pressed:!1,value:0}),n.sampleGamepadButtons(!0),n.update(0),e(p).toHaveBeenCalledOnce(),Object.assign(t,{pressed:!0,value:1}),n.update(0),e(p).toHaveBeenCalledTimes(2)}),t("modal suppression discards buffered taps and clears on a sampled release",()=>{const t={pressed:!0,value:1},s={mapping:"standard",buttons:[t],axes:[]},n=g(),o=a.fn();n.bindToggle("jump",o),n.setGamepadButtonBind("jump",new i(0)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.sampleGamepadButtons(!1),Object.assign(t,{pressed:!1,value:0}),n.sampleGamepadButtons(!1),n.suppressHeldGamepadButtonsUntilRelease(),n.update(0),e(o).not.toHaveBeenCalled(),Object.assign(t,{pressed:!0,value:1}),n.suppressHeldGamepadButtonsUntilRelease(),Object.assign(t,{pressed:!1,value:0}),n.sampleGamepadButtons(!1),Object.assign(t,{pressed:!0,value:1}),n.sampleGamepadButtons(!1),n.update(0),e(o.mock.calls).toEqual([[!0]])}),t("a callback can suppress other buffered buttons without leaving stale delivered states",()=>{const t=[{pressed:!1,value:0},{pressed:!1,value:0}],s={mapping:"standard",buttons:t,axes:[]},n=g(),o=a.fn();n.bindToggle("modal",e=>{e&&n.suppressHeldGamepadButtonsUntilRelease()}),n.bindToggle("jump",o),n.setGamepadButtonBind("modal",new i(0)),n.setGamepadButtonBind("jump",new i(1)),Object.defineProperty(navigator,"getGamepads",{configurable:!0,value:()=>[s]}),n.update(0),Object.assign(t[0],{pressed:!0,value:1}),Object.assign(t[1],{pressed:!0,value:1}),n.sampleGamepadButtons(!1),Object.assign(t[1],{pressed:!1,value:0}),n.sampleGamepadButtons(!1),n.update(0),e(o).not.toHaveBeenCalled(),Object.assign(t[1],{pressed:!0,value:1}),n.update(0),e(o.mock.calls).toEqual([[!0]])});/*
|
|
2
2
|
* Copyright (©) 2026 Hology Interactive AB. All rights reserved.
|
|
3
3
|
* See the LICENSE.md file for details.
|
|
4
4
|
*/
|