mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
portal effect (#203)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>spark | splat-portal</title>
|
||||
<style>
|
||||
html, body, #canvas { width: 100%; height: 100%; margin: 0; padding: 0; display: block; }
|
||||
body { background: #000; overflow: hidden; }
|
||||
.legend {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
color: white;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="legend">WASD + Mouse to move camera • Go through portal to switch between worlds</div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user