mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
406 lines
12 KiB
JavaScript
406 lines
12 KiB
JavaScript
import { SparkControls, SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
|
|
import * as THREE from "three";
|
|
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
|
|
|
const canvas = document.getElementById("canvas");
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
|
renderer.setClearColor(0x000000, 1);
|
|
|
|
// Two independent scenes (world A and world B)
|
|
const sceneA = new THREE.Scene();
|
|
const sceneB = new THREE.Scene();
|
|
const sparkA = new SparkRenderer({ renderer });
|
|
const sparkB = new SparkRenderer({ renderer });
|
|
sceneA.add(sparkA);
|
|
sceneB.add(sparkB);
|
|
|
|
// Main camera (not parented to scenes)
|
|
const camera = new THREE.PerspectiveCamera(
|
|
50,
|
|
canvas.clientWidth / canvas.clientHeight,
|
|
0.01,
|
|
2000,
|
|
);
|
|
camera.position.set(0, 1, 3);
|
|
camera.lookAt(0, 1, 0);
|
|
|
|
// Offscreen render targets for portal views
|
|
const rtAtoB = new THREE.WebGLRenderTarget(
|
|
canvas.clientWidth,
|
|
canvas.clientHeight,
|
|
{
|
|
depthBuffer: true,
|
|
},
|
|
);
|
|
rtAtoB.texture.minFilter = THREE.LinearFilter;
|
|
rtAtoB.texture.magFilter = THREE.LinearFilter;
|
|
rtAtoB.texture.generateMipmaps = false;
|
|
const rtBtoA = new THREE.WebGLRenderTarget(
|
|
canvas.clientWidth,
|
|
canvas.clientHeight,
|
|
{
|
|
depthBuffer: true,
|
|
},
|
|
);
|
|
rtBtoA.texture.minFilter = THREE.LinearFilter;
|
|
rtBtoA.texture.magFilter = THREE.LinearFilter;
|
|
rtBtoA.texture.generateMipmaps = false;
|
|
|
|
function resizeRenderTargets(width, height) {
|
|
const dpr =
|
|
typeof renderer.getPixelRatio === "function"
|
|
? renderer.getPixelRatio()
|
|
: window.devicePixelRatio || 1;
|
|
const w = Math.max(1, Math.floor(width * dpr));
|
|
const h = Math.max(1, Math.floor(height * dpr));
|
|
if (rtAtoB) {
|
|
rtAtoB.setSize(w, h);
|
|
}
|
|
if (rtBtoA) {
|
|
rtBtoA.setSize(w, h);
|
|
}
|
|
}
|
|
|
|
function handleResize() {
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
renderer.setSize(w, h, false);
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
resizeRenderTargets(w, h);
|
|
}
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
// Camera controls with mouse and WASD enabled
|
|
const controls = new SparkControls({ canvas: renderer.domElement });
|
|
controls.fpsMovement.enable = true;
|
|
controls.pointerControls.enable = true;
|
|
|
|
// Portal helpers
|
|
function makePortalMaterial() {
|
|
const material = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
tMap: { value: null },
|
|
portalPV: { value: new THREE.Matrix4() },
|
|
portalBridge: { value: new THREE.Matrix4() },
|
|
worldMatrix: { value: new THREE.Matrix4() },
|
|
circleRadius: { value: 0.6 },
|
|
time: { value: 0.0 },
|
|
waveStrength: { value: 0.001 },
|
|
waveSpeed: { value: 10.0 },
|
|
waveFrequency: { value: 50.0 },
|
|
edgeSoftness: { value: 0.2 },
|
|
},
|
|
vertexShader: `
|
|
varying vec3 vWorldPosition;
|
|
varying vec2 vLocalXY;
|
|
void main() {
|
|
vec4 worldPos = modelMatrix * vec4(position, 1.0);
|
|
vWorldPosition = worldPos.xyz;
|
|
vLocalXY = position.xy;
|
|
gl_Position = projectionMatrix * viewMatrix * worldPos;
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform sampler2D tMap;
|
|
uniform mat4 portalPV;
|
|
uniform mat4 portalBridge;
|
|
uniform float circleRadius;
|
|
uniform float time;
|
|
uniform float waveStrength;
|
|
uniform float waveSpeed;
|
|
uniform float waveFrequency;
|
|
uniform float edgeSoftness;
|
|
varying vec3 vWorldPosition;
|
|
varying vec2 vLocalXY;
|
|
void main() {
|
|
float r = length(vLocalXY);
|
|
if (r > circleRadius) discard;
|
|
|
|
// Generate radial waves
|
|
float wave = sin(r * waveFrequency - time * waveSpeed);
|
|
float distortion = wave * waveStrength;
|
|
|
|
// Deform UV radially based on wave
|
|
vec2 direction = normalize(vLocalXY);
|
|
vec2 offset = direction * distortion;
|
|
|
|
vec4 targetPos = portalBridge * vec4(vWorldPosition, 1.0);
|
|
vec4 clip = portalPV * targetPos;
|
|
vec3 ndc = clip.xyz / max(clip.w, 1e-6);
|
|
vec2 uv = ndc.xy * 0.5 + 0.5;
|
|
|
|
// Apply wave distortion to UV
|
|
uv += offset;
|
|
|
|
// Clamp UVs instead of discarding to avoid black edges
|
|
uv = clamp(uv, vec2(0.0), vec2(1.0));
|
|
vec4 color = texture2D(tMap, uv);
|
|
color+=offset.x*50.;
|
|
|
|
// Edge fadeout by reducing brightness instead of alpha
|
|
float edgeFade = 1.0 - smoothstep(circleRadius * (1.0 - edgeSoftness), circleRadius, r);
|
|
color.rgb *= .1+edgeFade;
|
|
|
|
gl_FragColor = color;
|
|
}
|
|
`,
|
|
side: THREE.DoubleSide,
|
|
transparent: false,
|
|
depthWrite: true,
|
|
depthTest: true,
|
|
});
|
|
return material;
|
|
}
|
|
|
|
function makePortal(radius) {
|
|
const geom = new THREE.CircleGeometry(radius, 64);
|
|
const mat = makePortalMaterial();
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
mesh.renderOrder = 1000;
|
|
return mesh;
|
|
}
|
|
|
|
function buildPVMatrix(cameraObj) {
|
|
const pv = new THREE.Matrix4();
|
|
pv.multiplyMatrices(cameraObj.projectionMatrix, cameraObj.matrixWorldInverse);
|
|
return pv;
|
|
}
|
|
|
|
function buildPortalBridgeMatrix(sourcePortal, targetPortal) {
|
|
// Bridge = targetPortal * Ry(PI) * inverse(sourcePortal)
|
|
const invSrc = new THREE.Matrix4().copy(sourcePortal.matrixWorld).invert();
|
|
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
|
|
const tmp = new THREE.Matrix4().multiplyMatrices(rotY180, invSrc);
|
|
const bridge = new THREE.Matrix4().multiplyMatrices(
|
|
targetPortal.matrixWorld,
|
|
tmp,
|
|
);
|
|
return bridge;
|
|
}
|
|
|
|
function computeLinkedCamera(
|
|
sourcePortal,
|
|
targetPortal,
|
|
fromCamera,
|
|
outCamera,
|
|
clampRadius = 0,
|
|
) {
|
|
// outCameraWorld = targetPortal * R_y(PI) * inverse(sourcePortal) * fromCameraWorld
|
|
const invSrc = new THREE.Matrix4().copy(sourcePortal.matrixWorld).invert();
|
|
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
|
|
const tmp = new THREE.Matrix4().multiplyMatrices(
|
|
invSrc,
|
|
fromCamera.matrixWorld,
|
|
);
|
|
const withRot = new THREE.Matrix4().multiplyMatrices(rotY180, tmp);
|
|
const dst = new THREE.Matrix4().multiplyMatrices(
|
|
targetPortal.matrixWorld,
|
|
withRot,
|
|
);
|
|
|
|
outCamera.matrixWorld.copy(dst);
|
|
outCamera.matrixWorld.decompose(
|
|
outCamera.position,
|
|
outCamera.quaternion,
|
|
outCamera.scale,
|
|
);
|
|
|
|
// Clamp camera position if radius is specified
|
|
if (clampRadius > 0) {
|
|
const targetPos = new THREE.Vector3();
|
|
targetPortal.getWorldPosition(targetPos);
|
|
|
|
const toCam = outCamera.position.clone().sub(targetPos);
|
|
const distance = toCam.length();
|
|
|
|
if (distance > clampRadius) {
|
|
toCam.normalize().multiplyScalar(clampRadius);
|
|
outCamera.position.copy(targetPos).add(toCam);
|
|
outCamera.updateMatrixWorld(true);
|
|
}
|
|
}
|
|
|
|
outCamera.projectionMatrix.copy(fromCamera.projectionMatrix);
|
|
outCamera.updateMatrixWorld(true);
|
|
}
|
|
|
|
function transformPoseThroughPortal(sourcePortal, targetPortal, object3D) {
|
|
const srcWorld = sourcePortal.matrixWorld;
|
|
const dstWorld = targetPortal.matrixWorld;
|
|
const invSrc = new THREE.Matrix4().copy(srcWorld).invert();
|
|
const localMat = new THREE.Matrix4().multiplyMatrices(
|
|
invSrc,
|
|
object3D.matrixWorld,
|
|
);
|
|
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
|
|
const dstMat = new THREE.Matrix4().multiplyMatrices(
|
|
dstWorld,
|
|
new THREE.Matrix4().multiplyMatrices(rotY180, localMat),
|
|
);
|
|
object3D.matrixWorld.copy(dstMat);
|
|
object3D.matrixWorld.decompose(
|
|
object3D.position,
|
|
object3D.quaternion,
|
|
object3D.scale,
|
|
);
|
|
}
|
|
|
|
async function run() {
|
|
// Load valley (world A)
|
|
const valleyURL = await getAssetFileURL("valley.spz");
|
|
const valley = new SplatMesh({ url: valleyURL });
|
|
await valley.initialized;
|
|
valley.rotateX(Math.PI);
|
|
sceneA.add(valley);
|
|
|
|
// Load sutro (world B)
|
|
const sutroURL = await getAssetFileURL("sutro.zip");
|
|
const sutro = new SplatMesh({ url: sutroURL });
|
|
await sutro.initialized;
|
|
sutro.rotateX(Math.PI); // Fix orientation
|
|
sutro.position.set(0, 0, 0);
|
|
sutro.scale.set(3.5, 3.5, 3.5);
|
|
sceneB.add(sutro);
|
|
|
|
// Portals in each world
|
|
const portalRadius = 0.6;
|
|
const portalA = makePortal(portalRadius);
|
|
portalA.position.set(0, 1, -2);
|
|
portalA.rotation.set(0, 0, 0);
|
|
sceneA.add(portalA);
|
|
|
|
const portalB = makePortal(portalRadius);
|
|
portalB.position.set(-1, 1, -5);
|
|
portalB.rotation.set(0, Math.PI, 0);
|
|
sceneB.add(portalB);
|
|
|
|
portalA.updateMatrixWorld(true);
|
|
portalB.updateMatrixWorld(true);
|
|
|
|
// Offscreen cameras
|
|
const camAtoB = new THREE.PerspectiveCamera();
|
|
const camBtoA = new THREE.PerspectiveCamera();
|
|
|
|
// Teleportation tracking
|
|
let activeWorld = "A";
|
|
let lastPortalSide = -1; // -1 = behind portal, 1 = in front
|
|
let teleportCooldown = 0;
|
|
const TELEPORT_COOLDOWN_MS = 500; // 500ms cooldown
|
|
const CROSSING_THRESHOLD = 0.3; // Distance threshold for crossing detection
|
|
|
|
function getDistanceFromPortal(portal, pointWorld) {
|
|
const local = pointWorld.clone();
|
|
portal.worldToLocal(local);
|
|
return local.z; // positive = in front, negative = behind
|
|
}
|
|
|
|
function withinRadius(portal, pointWorld, radius) {
|
|
const local = pointWorld.clone();
|
|
portal.worldToLocal(local);
|
|
const d = Math.hypot(local.x, local.y);
|
|
return d <= radius;
|
|
}
|
|
|
|
renderer.setAnimationLoop((timeMs) => {
|
|
// Decrease cooldown timer
|
|
if (teleportCooldown > 0) {
|
|
teleportCooldown -= 16; // assuming ~60fps
|
|
}
|
|
|
|
// Controls and camera updates
|
|
controls.update(camera);
|
|
camera.updateMatrixWorld(true);
|
|
|
|
// Update time for wave animation
|
|
const time = timeMs * 0.001; // Convert to seconds
|
|
portalA.material.uniforms.time.value = time;
|
|
portalB.material.uniforms.time.value = time;
|
|
|
|
// Prepare portal cameras
|
|
portalA.updateMatrixWorld(true);
|
|
portalB.updateMatrixWorld(true);
|
|
|
|
computeLinkedCamera(portalA, portalB, camera, camAtoB, 3);
|
|
computeLinkedCamera(portalB, portalA, camera, camBtoA, 3);
|
|
|
|
// Render other worlds into targets (hide portals to avoid recursion/artifacts)
|
|
const prevAVisible = portalA.visible;
|
|
const prevBVisible = portalB.visible;
|
|
portalA.visible = false;
|
|
portalB.visible = false;
|
|
|
|
renderer.setRenderTarget(rtAtoB);
|
|
renderer.clear(true, true, true);
|
|
renderer.render(sceneB, camAtoB);
|
|
renderer.setRenderTarget(rtBtoA);
|
|
renderer.clear(true, true, true);
|
|
renderer.render(sceneA, camBtoA);
|
|
renderer.setRenderTarget(null);
|
|
|
|
portalA.visible = prevAVisible;
|
|
portalB.visible = prevBVisible;
|
|
|
|
// Update portal materials with PV matrices composed with portal bridges
|
|
const pvA = buildPVMatrix(camAtoB);
|
|
const pvB = buildPVMatrix(camBtoA);
|
|
const bridgeAtoB = buildPortalBridgeMatrix(portalA, portalB);
|
|
const bridgeBtoA = buildPortalBridgeMatrix(portalB, portalA);
|
|
const matA = portalA.material;
|
|
const matB = portalB.material;
|
|
matA.uniforms.tMap.value = rtAtoB.texture;
|
|
matA.uniforms.portalPV.value.copy(pvA);
|
|
matA.uniforms.portalBridge.value.copy(bridgeAtoB);
|
|
matB.uniforms.tMap.value = rtBtoA.texture;
|
|
matB.uniforms.portalPV.value.copy(pvB);
|
|
matB.uniforms.portalBridge.value.copy(bridgeBtoA);
|
|
|
|
// Teleport logic when crossing the active world's portal
|
|
if (teleportCooldown <= 0) {
|
|
const camPos = camera.position.clone();
|
|
const activePortal = activeWorld === "A" ? portalA : portalB;
|
|
const distance = getDistanceFromPortal(activePortal, camPos);
|
|
const currentSide = Math.sign(distance);
|
|
const absDistance = Math.abs(distance);
|
|
|
|
// Check if we're crossing the portal (from either side)
|
|
const isCrossing =
|
|
absDistance < CROSSING_THRESHOLD &&
|
|
withinRadius(activePortal, camPos, portalRadius * 1.2);
|
|
|
|
// Detect side change: going from front to back or back to front
|
|
const sideChanged =
|
|
lastPortalSide !== 0 && currentSide !== lastPortalSide;
|
|
|
|
if (isCrossing && (sideChanged || absDistance < 0.1)) {
|
|
const src = activeWorld === "A" ? portalA : portalB;
|
|
const dst = activeWorld === "A" ? portalB : portalA;
|
|
|
|
transformPoseThroughPortal(src, dst, camera);
|
|
// Calculate forward direction relative to the destination portal
|
|
const forward = new THREE.Vector3(0, 0, -1)
|
|
.applyQuaternion(camera.quaternion)
|
|
.multiplyScalar(0.2);
|
|
camera.position.add(forward);
|
|
camera.updateMatrixWorld(true);
|
|
activeWorld = activeWorld === "A" ? "B" : "A";
|
|
teleportCooldown = TELEPORT_COOLDOWN_MS;
|
|
lastPortalSide = currentSide; // Update side tracking
|
|
} else {
|
|
lastPortalSide = currentSide;
|
|
}
|
|
}
|
|
|
|
// Render active world to screen
|
|
if (activeWorld === "A") {
|
|
renderer.render(sceneA, camera);
|
|
} else {
|
|
renderer.render(sceneB, camera);
|
|
}
|
|
});
|
|
}
|
|
|
|
run();
|