Compare commits

..

45 Commits

Author SHA1 Message Date
neru ab3bf047d4 style: reduce noise opacity 2026-06-02 04:43:52 -03:00
neru f1ab2b692d style: change text 2026-06-02 04:43:09 -03:00
neru 673aabce50 style: move patch to its own file 2026-06-02 04:42:16 -03:00
neru b3a5712c85 feat: make movement jitter, add phase effects 2026-06-02 04:42:07 -03:00
neru 4691a9fbf4 feat: add flicker anim 2026-06-02 04:30:03 -03:00
neru 0fca4db440 style: change size and alignment 2026-06-02 04:13:54 -03:00
neru 930139d1df feat: add unicode glitches 2026-06-02 04:13:29 -03:00
neru 4120e5ec72 feat: move href logic to state mgr 2026-06-02 03:57:42 -03:00
neru fd314cf2ec feat: make finale text infinite 2026-06-02 03:57:31 -03:00
neru e3ab974988 style: reduce fov 2026-06-02 03:45:04 -03:00
neru e4a0c57e79 feat: add TEST_MODE 2026-06-02 03:44:58 -03:00
neru ebda4b281e feat: add steel texture for doors 2026-06-02 03:29:17 -03:00
neru 8dcc888d5c feat: add psx style vertex snapping and affine distortion 2026-06-02 03:29:07 -03:00
neru b7e61b4240 feat: lerp flashlight pos 2026-06-02 02:50:11 -03:00
neru 23c39a71a6 feat: add footsteps 2026-06-02 02:50:04 -03:00
neru a0ee50703c feat: add better ambience sound 2026-06-02 02:44:44 -03:00
neru 8c4080f10c feat: add finale text 2026-06-01 22:02:22 -03:00
neru b9eeed848b feat: add door knocks 2026-06-01 22:02:12 -03:00
neru d506071ce2 feat: overhaul grass 2026-06-01 17:31:13 -03:00
neru cad47f07bd feat: randomize grass height 2026-06-01 17:02:05 -03:00
neru 10543bba89 fix: make grass rotation less strong 2026-06-01 17:00:33 -03:00
neru aeee2158ba feat/fix: misc shader changes (fix normals, lighting, uv) 2026-06-01 16:56:33 -03:00
neru 569a4f29fb fix: make fog fit hdr 2026-06-01 16:50:55 -03:00
neru 9a67a800fa feat: add hover anim 2026-06-01 16:49:04 -03:00
neru 719a75d393 feat: change cube lighting to standard mat 2026-06-01 16:49:00 -03:00
neru f583cfdc57 style: formatting 2026-06-01 16:47:44 -03:00
neru 5665804b8f fix: disable AA (already handled by SMAA) 2026-06-01 16:47:37 -03:00
neru 67bf6325fa fix: misc visual changes 2026-06-01 16:47:24 -03:00
neru 6d7651dec9 feat: implement acceleration, prevent bob from offsetting root pos 2026-06-01 15:50:46 -03:00
neru ee2eb45527 fix: preload creature tex 2026-06-01 15:42:25 -03:00
neru 079986ebec fix: move sounds outside suspense 2026-06-01 15:42:17 -03:00
neru a0b416c412 fix: remove dbg loop counts 2026-06-01 15:39:36 -03:00
neru c582d6b745 fix?: ensure canvas position and block selection 2026-06-01 15:38:26 -03:00
neru beff5e3265 feat: rely on state mgr instead of hardcoded loop count 2026-06-01 15:38:04 -03:00
neru c04d8536c0 fix: issue with AmbientSound not updating correctly 2026-06-01 15:37:46 -03:00
neru cb15cc3d95 feat: add ambient2, add keys to sounds 2026-06-01 15:37:23 -03:00
neru 0d72d49d7b fix: move ambient light to page root 2026-06-01 15:37:08 -03:00
neru d66c898f23 feat: move state updates to page root 2026-06-01 15:36:21 -03:00
neru 566a684bfa fix?: try to fix camera snapping to rnd angles 2026-06-01 15:35:56 -03:00
neru df81fc1ee0 style: adjust flashlight params 2026-06-01 15:08:10 -03:00
neru dd5e8a2ae2 feat: add CREATURE_SPEED and FLASHLIGHT_INTENSITY_BASE 2026-06-01 15:08:03 -03:00
neru 20b6a559fd fix: add separate rust texture for walls to stop stretching 2026-06-01 15:07:01 -03:00
neru eec01440f9 feat: it speaks 2026-05-31 23:41:32 -03:00
neru 339c660bcb feat: he is here. 2026-05-31 23:24:14 -03:00
neru fe686b0071 fix: audio handling 2026-05-31 22:03:52 -03:00
30 changed files with 1279 additions and 592 deletions
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -1,4 +1,10 @@
.canvas {
width: 100vw !important;
height: 100vh !important;
position: absolute;
top: 0;
left: 0;
outline: none;
user-select: none;
touch-action: none;
}
+64 -468
View File
@@ -2,498 +2,73 @@
import './page.css';
import { PointerLockControls, useTexture } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { BrightnessContrast, EffectComposer, Noise, Pixelation, Vignette } from "@react-three/postprocessing";
import { Suspense, useEffect, useRef, useState } from "react";
import { BrightnessContrast, EffectComposer, HueSaturation, Noise, Pixelation, Vignette } from "@react-three/postprocessing";
import { Suspense, useEffect, useState } from "react";
import * as THREE from "three";
import { AmbientSound } from './scene-components/ambient-sound';
import { fearState, FEAR_SETTINGS } from './state';
import { FEAR_SETTINGS, fearState } from './state';
function Player() {
const { camera } = useThree();
const controls = usePlayerControls();
import TheCreature from './scene-components/creature';
import Player from './scene-components/player';
import Hallway from './scene-components/hallway';
const flashlightRef = useRef<THREE.SpotLight>(null);
const movementCounter = useRef<number>(0);
import { AudioListener } from 'three';
import FinaleText from './scene-components/finale-text';
const forward = new THREE.Vector3();
const side = new THREE.Vector3();
const direction = new THREE.Vector3();
const viewDirection = new THREE.Vector3();
useFrame((state, delta) => {
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
side.crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const moveForward = Number(controls.Forward) - Number(controls.Backward);
const moveSide = Number(controls.Right) - Number(controls.Left);
direction.set(0, 0, 0);
if (moveForward !== 0) direction.addScaledVector(forward, moveForward);
if (moveSide !== 0) direction.addScaledVector(side, moveSide);
if (direction.lengthSq() > 0)
direction.normalize().multiplyScalar(4 * delta);
camera.position.x += direction.x;
camera.position.z += direction.z;
const isMoving = controls.Forward || controls.Backward || controls.Left || controls.Right;
if (isMoving) {
movementCounter.current += delta * 10;
camera.position.y = FEAR_SETTINGS.PLAYER_HEIGHT + Math.sin(movementCounter.current) * 0.08;
camera.position.x += Math.cos(movementCounter.current / 2) * 0.006;
} else {
const breatheTime = state.clock.elapsedTime * 1.5;
const breatheY = FEAR_SETTINGS.PLAYER_HEIGHT + Math.sin(breatheTime) * 0.1;
camera.position.y = THREE.MathUtils.lerp(camera.position.y, breatheY, 4 * delta);
}
if (flashlightRef.current) {
flashlightRef.current.position.copy(camera.position);
camera.getWorldDirection(viewDirection);
const targetDest = new THREE.Vector3()
.copy(camera.position)
.addScaledVector(viewDirection, 10);
flashlightRef.current.target.position.lerp(targetDest, 10 * delta);
flashlightRef.current.target.updateMatrixWorld();
flashlightRef.current.intensity = 5 + Math.sin(state.clock.elapsedTime * 30) * 0.3;
}
const minX = -fearState.currentWidth / 2 + FEAR_SETTINGS.WALL_BUFFER;
const maxX = fearState.currentWidth / 2 - FEAR_SETTINGS.WALL_BUFFER;
if (camera.position.x < minX) camera.position.x = minX;
if (camera.position.x > maxX) camera.position.x = maxX;
if (camera.position.z < -FEAR_SETTINGS.HALLWAY_LENGTH) {
camera.position.z += FEAR_SETTINGS.HALLWAY_LENGTH;
fearState.registerLoop('forward');
}
if (camera.position.z > 0) {
camera.position.z -= FEAR_SETTINGS.HALLWAY_LENGTH;
fearState.registerLoop('backward');
}
});
return (
<>
<PointerLockControls />
<spotLight
ref={flashlightRef}
distance={22}
angle={0.35}
penumbra={0.7}
intensity={5}
color="#fffaed"
/>
</>
);
}
function usePlayerControls() {
const keys = useRef({ Forward: false, Backward: false, Left: false, Right: false });
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = true;
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = true;
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = true;
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = true;
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = false;
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = false;
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = false;
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = false;
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
return keys.current;
}
interface DoorProps {
position: [number, number, number];
rotation: [number, number, number];
}
function Door({ position, rotation }: DoorProps) {
return (
<group position={position} rotation={rotation}>
<mesh position={[0, 2, -0.14]}>
<boxGeometry args={[2.4, 4.0, 0.2]} />
<meshStandardMaterial color="#8a8585" roughness={0.8} metalness={0.2} />
</mesh>
<mesh position={[0, 1.95, -0.08]}>
<boxGeometry args={[2.1, 3.8, 0.1]} />
<meshStandardMaterial color="#4e4b4b" roughness={0.7} metalness={0.2} />
</mesh>
<mesh position={[0.9, 1.8, 0.08]}>
<boxGeometry args={[0.08, 0.08, 0.15]} />
<meshStandardMaterial color="#4e4b4b" roughness={0.4} metalness={0.2} />
</mesh>
</group>
);
}
function Hallway() {
const [width, setWidth] = useState(fearState.currentWidth);
const [floorTex, wallTex, rustTex] = useTexture([
'fear/img/concrete-floor.png',
'fear/img/concrete-wall.png',
'fear/img/rust.png'
]);
[floorTex, wallTex, rustTex].forEach((tex, index) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.minFilter = tex.magFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
tex.repeat.set(index === 0 ? 1 : 10, index === 0 ? 10 : 1);
});
const segments = [0, -1, 1, -2, 2];
const lightRef1 = useRef<THREE.PointLight>(null);
const lightRef2 = useRef<THREE.PointLight>(null);
const matRef1 = useRef<THREE.MeshStandardMaterial>(null);
const matRef2 = useRef<THREE.MeshStandardMaterial>(null);
const lightState = useRef<'normal' | 'flickering' | 'dead'>('normal');
const stateEndTime = useRef<number>(0);
const nextEventTime = useRef<number>(5);
const segmentsRef = useRef<THREE.Group[]>([]);
const wallMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
const floorMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
const pipeMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
const bracketMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
wallMaterialsRef.current = [];
floorMaterialsRef.current = [];
pipeMaterialsRef.current = [];
bracketMaterialsRef.current = [];
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
function PostProcessing() {
const [wasCaught, setWasCaught] = useState(fearState.wasCaught);
useEffect(() => {
const unsubscribe = fearState.subscribe(() => {
setWidth(fearState.currentWidth);
setIsRustActive(fearState.isRustActive);
setWasCaught(fearState.wasCaught);
});
return () => unsubscribe();
}, []);
useFrame((state, delta) => {
const time = state.clock.elapsedTime;
fearState.update(delta);
const currentRust = fearState.isRustActive;
if (currentRust !== isRustActive)
setIsRustActive(currentRust);
/*
lights
*/
let intensity1 = 0.85 + Math.sin(time * 2) * 0.03;
if (time > nextEventTime.current && lightState.current === 'normal') {
lightState.current = 'flickering';
stateEndTime.current = time + 1.5 + Math.random() * 2;
}
if (lightState.current === 'flickering') {
if (time > stateEndTime.current) {
if (Math.random() > 0.4) {
lightState.current = 'dead';
stateEndTime.current = time + 1.0 + Math.random() * 2.5;
} else {
lightState.current = 'normal';
nextEventTime.current = time + 10 + Math.random() * 20;
}
} else {
const baseWave = Math.sin(time * 45) * 0.4 + Math.sin(time * 90) * 0.3;
intensity1 = 0.5 + baseWave;
if (Math.sin(time * 150) + Math.cos(time * 220) > 1.2) intensity1 *= Math.random() > 0.5 ? 0.0 : 0.15;
}
}
if (lightState.current === 'dead') {
if (time > stateEndTime.current) {
lightState.current = 'normal';
nextEventTime.current = time + 12 + Math.random() * 15;
} else
intensity1 = Math.random() > 0.98 ? 0.08 : 0.0;
}
let intensity2 = lightState.current === 'dead' ? 0 : intensity1 * (0.7 + Math.sin(time * 3) * 0.1);
if (lightRef1.current) lightRef1.current.intensity = intensity1 * 1.2;
if (matRef1.current) {
matRef1.current.emissiveIntensity = intensity1 * 2.5;
if (lightState.current !== 'normal') matRef1.current.emissive.setHSL(0.07, 0.4, Math.min(intensity1, 0.7));
else matRef1.current.emissive.setHex(0xa8a1a1);
}
if (lightRef2.current) lightRef2.current.intensity = intensity2 * 0.6;
if (matRef2.current) matRef2.current.emissiveIntensity = intensity2 * 1.5;
/*
walls
*/
segmentsRef.current.forEach((segGroup) => {
if (!segGroup) return;
const leftWallGroup = segGroup.getObjectByName("left-wall-group");
if (leftWallGroup) leftWallGroup.position.x = -width / 2;
const rightWallGroup = segGroup.getObjectByName("right-wall-group");
if (rightWallGroup) rightWallGroup.position.x = width / 2;
const floorMesh = segGroup.getObjectByName("floor-mesh");
if (floorMesh) floorMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
const ceilingMesh = segGroup.getObjectByName("ceiling-mesh");
if (ceilingMesh) ceilingMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
for (let i = 0; i < 3; i++) {
const pipe = segGroup.getObjectByName(`pipe-${i}`);
if (pipe) pipe.position.x = -width / 2 + 0.4 + (i * 0.20);
}
const bracketGroup = segGroup.getObjectByName("brackets-group");
if (bracketGroup) {
bracketGroup.children.forEach(b => {
b.position.x = -width / 2 + 0.6;
});
}
});
/*
rust
*/
wallMaterialsRef.current.forEach(mat => {
if (!mat) return;
const targetTex = isRustActive ? rustTex : wallTex;
if (mat.map !== targetTex) {
mat.map = targetTex;
mat.needsUpdate = true;
}
if (isRustActive) {
mat.color.set("#918a87");
mat.roughness = 0.95;
mat.metalness = 0.05;
} else {
mat.color.set("#ffffff");
mat.roughness = 0.7;
mat.metalness = 0.1;
}
});
floorMaterialsRef.current.forEach(mat => {
if (!mat) return;
const targetTex = isRustActive ? rustTex : floorTex;
if (mat.map !== targetTex) {
mat.map = targetTex;
mat.needsUpdate = true;
}
if (isRustActive) {
mat.color.set("#8b827f");
mat.roughness = 0.95;
mat.metalness = 0.05;
} else {
mat.color.set("#ffffff");
mat.roughness = 0.8;
mat.metalness = 0.2;
}
});
pipeMaterialsRef.current.forEach(mat => {
if (!mat) return;
mat.color.set(isRustActive ? "#3d1b0f" : "#a5aca8");
mat.roughness = isRustActive ? 0.95 : 0.0;
mat.metalness = isRustActive ? 0.05 : 0.4;
});
bracketMaterialsRef.current.forEach(mat => {
if (!mat) return;
mat.color.set(isRustActive ? "#1b0b05" : "#a5aca8");
mat.roughness = isRustActive ? 0.95 : 0.0;
mat.metalness = isRustActive ? 0.05 : 0.4;
});
});
return (
<>
<ambientLight intensity={0.0225} />
{segments.map((segmentValue, index) => (
/*
lights
*/
<group
key={segmentValue}
ref={(el) => { if (el) segmentsRef.current[index] = el; }}
position={[0, 0, segmentValue * FEAR_SETTINGS.HALLWAY_LENGTH]}
>
<group position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.1, -FEAR_SETTINGS.HALLWAY_LENGTH / 4]}>
<pointLight
ref={segmentValue === 0 ? lightRef1 : null}
intensity={0.9}
distance={15}
color="#a8a1a1"
/>
<mesh position={[0, 0.09, 0]}>
<boxGeometry args={[0.3, 0.01, 0.3]} />
<meshStandardMaterial
ref={segmentValue === 0 ? matRef1 : null}
color="#111111"
emissive="#a8a1a1"
emissiveIntensity={segmentValue === 0 ? 0 : 0.8}
roughness={0.9}
/>
</mesh>
</group>
{/* floor */}
<mesh
name="floor-mesh"
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH]} />
<meshStandardMaterial
ref={(el) => el && floorMaterialsRef.current.push(el)}
map={floorTex}
roughness={0.8}
metalness={0.2}
/>
</mesh>
{/* ceiling */}
<mesh
name="ceiling-mesh"
rotation={[Math.PI / 2, 0, 0]}
position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH]} />
<meshStandardMaterial
ref={(el) => el && floorMaterialsRef.current.push(el)}
map={floorTex}
roughness={0.8}
metalness={0.2}
/>
</mesh>
{/* l wall */}
<group name="left-wall-group">
<mesh rotation={[0, Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT]} />
<meshStandardMaterial
ref={(el) => el && wallMaterialsRef.current.push(el)}
map={wallTex}
roughness={0.7}
metalness={0.1}
/>
</mesh>
{!isRustActive && (
<>
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.25]} rotation={[0, Math.PI / 2, 0]} />
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.85]} rotation={[0, Math.PI / 2, 0]} />
</>
)}
</group>
{/* r wall */}
<group name="right-wall-group">
<mesh rotation={[0, -Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT]} />
<meshStandardMaterial
ref={(el) => el && wallMaterialsRef.current.push(el)}
map={wallTex}
roughness={0.7}
metalness={0.1}
/>
</mesh>
{!isRustActive && (
<Door position={[-0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.65]} rotation={[0, -Math.PI / 2, 0]} />
)}
</group>
{/* pipes */}
{Array.from({ length: 3 }).map((_, idx) => (
<mesh
key={idx}
name={`pipe-${idx}`}
rotation={[Math.PI / 2, 0, 0]}
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.4 + (idx * 0.20), FEAR_SETTINGS.HALLWAY_HEIGHT - 0.2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
>
<cylinderGeometry args={[0.06, 0.06, FEAR_SETTINGS.HALLWAY_LENGTH, 4]} />
<meshStandardMaterial
ref={(el) => el && pipeMaterialsRef.current.push(el)}
color="#a5aca8"
roughness={0.0}
metalness={0.4}
/>
</mesh>
))}
{/* brackets */}
<group name="brackets-group">
{Array.from({ length: 5 }).map((_, idx) => {
const zOffset = -(idx * 8 + 4);
return (
<mesh
key={`bracket-${idx}`}
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.6, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.15, zOffset]}
>
<boxGeometry args={[0.7, 0.3, 0.15]} />
<meshStandardMaterial
ref={(el) => el && bracketMaterialsRef.current.push(el)}
color="#a5aca8"
roughness={0.0}
metalness={0.4}
/>
</mesh>
);
})}
</group>
</group>
))}
</>
);
}
function PostProcessing() {
return (<EffectComposer>
<Pixelation granularity={12} />
<Pixelation granularity={wasCaught ? 18 : 10} />
<Vignette />
<Noise opacity={0.005} />
<Noise opacity={wasCaught ? 0.01 : 0.003} />
<BrightnessContrast
brightness={-0.01}
contrast={0.05}
/>
<HueSaturation saturation={wasCaught ? 1 : 0} />
</EffectComposer>)
}
function ListenerCreator() {
const { camera } = useThree();
useEffect(() => {
const listener = new AudioListener();
camera.add(listener);
return () => {
camera.remove(listener);
};
}, [camera]);
return null;
}
function FearStateUpdater() {
useFrame((state, delta) => {
fearState.update(delta);
});
return null;
}
export default function Fear() {
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
const [wasCaught, setWasCaught] = useState(fearState.isRustActive);
useEffect(() => {
const unsubscribe = fearState.subscribe(() => {
setIsRustActive(fearState.isRustActive);
setWasCaught(fearState.wasCaught)
});
return () => unsubscribe();
}, []);
@@ -503,22 +78,43 @@ export default function Fear() {
shadows
gl={{ antialias: true }}
className='canvas'
camera={{ position: [0, 3, -5], fov: 65, far: 100 }}
camera={{ position: [0, 3, -5], fov: 55, far: 100 }}
>
<FearStateUpdater />
<ListenerCreator />
<color attach="background" args={['#050505']} />
<fogExp2 attach='fog' args={[0x050505, 0.035]} />
<PostProcessing />
{FEAR_SETTINGS.TEST_MODE ? <ambientLight intensity={2} /> : <ambientLight intensity={0.0225} />}
{FEAR_SETTINGS.TEST_MODE ? null : <fogExp2 attach='fog' args={[0x050505, 0.035]} />}
{FEAR_SETTINGS.TEST_MODE ? null : < PostProcessing />}
<Suspense fallback={null}>
<Hallway />
<TheCreature />
<Player />
</Suspense>
<AmbientSound
key="ambient-1"
url='fear/snd/ambience.mp3'
volume={isRustActive ? 0 : 1}
volume={isRustActive ? 0 : 0.5}
/>
<Player />
<AmbientSound
key="ambient-2"
url='fear/snd/ambience2.mp3'
volume={isRustActive ? 1 : 0}
/>
{wasCaught ? <AmbientSound
key="ambient-glitch"
url='fear/snd/glitch.mp3'
volume={1}
/> : null}
</Canvas>
<FinaleText />
</>)
}
+31 -10
View File
@@ -7,35 +7,56 @@ interface AmbientSoundProps {
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
const audioRef = useRef<HTMLAudioElement | null>(null)
const targetVolumeRef = useRef<number>(volume)
targetVolumeRef.current = volume
useEffect(() => {
const audio = new Audio(url)
audio.loop = true
audio.volume = volume
audio.volume = 0
audioRef.current = audio
const startAudio = () => {
let componentsMounted = true
const attemptPlay = () => {
if (!audioRef.current || !componentsMounted) return
audio.volume = targetVolumeRef.current
if (audio.volume > 0 && audio.paused) {
audio.play().catch((err) => {
console.warn('Autoplay blocked. Waiting for user interaction.', err)
console.warn('Autoplay management holding clip playback execution.', err)
})
}
}
startAudio()
attemptPlay()
window.addEventListener('click', startAudio, { once: true })
window.addEventListener('keydown', startAudio, { once: true })
window.addEventListener('click', attemptPlay)
window.addEventListener('keydown', attemptPlay)
return () => {
window.removeEventListener('click', startAudio)
window.removeEventListener('keydown', startAudio)
componentsMounted = false
window.removeEventListener('click', attemptPlay)
window.removeEventListener('keydown', attemptPlay)
audio.pause()
audio.src = ''
audioRef.current = null
}
}, [url])
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = volume
const audio = audioRef.current
if (!audio) return
if (volume === 0) {
if (!audio.paused) audio.pause()
} else {
audio.volume = volume
if (audio.paused) {
audio.play().catch(() => {})
}
}
}, [volume])
+196
View File
@@ -0,0 +1,196 @@
import { useTexture, PositionalAudio } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { FEAR_SETTINGS, fearState } from "../state";
import { ShaderPatch } from "../shader-patch";
useTexture.preload('fear/img/creature.png');
export default function TheCreature() {
const baseTexture = useTexture('fear/img/creature.png');
const texture = useMemo(() => {
const t = baseTexture.clone();
t.needsUpdate = true;
return t;
}, [baseTexture]);
const meshRef = useRef<THREE.Mesh>(null);
const audioRef = useRef<THREE.PositionalAudio>(null);
const { camera } = useThree();
const [hasTriggered, setHasTriggered] = useState(false);
const [isSpawned, setIsSpawned] = useState(false);
const globalDistance = useRef<number>(32);
const [finaleTriggered, setFinaleTriggered] = useState(fearState.finaleTriggered);
const audioPlaying = useRef<boolean>(false);
const movePhase = useRef<'frozen' | 'lurching'>('frozen');
const phaseTimer = useRef<number>(1.5);
const glitchCooldown = useRef<number>(0);
const isGlitchSpiking = useRef<boolean>(false);
const flickerCooldown = useRef<number>(0);
useEffect(() => {
const unsubscribe = fearState.subscribe(() => {
setFinaleTriggered(fearState.finaleTriggered);
if (!fearState.finaleTriggered) {
setIsSpawned(false);
setHasTriggered(false);
globalDistance.current = 32;
audioPlaying.current = false;
movePhase.current = 'frozen';
phaseTimer.current = 1.5;
if (audioRef.current && audioRef.current.isPlaying)
audioRef.current.stop();
}
});
return () => unsubscribe();
}, []);
useFrame((state, delta) => {
if (!fearState.finaleTriggered) return;
const creature = meshRef.current;
if (!creature) return;
if (!isSpawned) {
setIsSpawned(true);
globalDistance.current = 32;
movePhase.current = 'frozen';
phaseTimer.current = 1.0 + Math.random() * 1.5;
}
if (!hasTriggered) {
if (globalDistance.current < 40)
setHasTriggered(true);
}
if (hasTriggered) {
phaseTimer.current -= delta;
if (phaseTimer.current <= 0) {
if (movePhase.current === 'frozen') {
movePhase.current = 'lurching';
phaseTimer.current = 0.05 + Math.random() * 0.2;
} else {
movePhase.current = 'frozen';
const proximityFactor = Math.max(0.05, globalDistance.current / 32);
phaseTimer.current = (0.2 + Math.random() * 1.0) * proximityFactor;
}
}
if (movePhase.current === 'lurching') {
globalDistance.current -= FEAR_SETTINGS.CREATURE_SPEED * 3 * delta;
}
if (audioRef.current && !audioPlaying.current) {
audioPlaying.current = true;
if (audioRef.current.context.state === 'suspended')
audioRef.current.context.resume();
audioRef.current.play();
}
const shakeIntensity = Math.max(0, 1 - (globalDistance.current / 32)) * 0.22;
camera.position.x += (Math.random() - 0.5) * shakeIntensity;
camera.position.y += (Math.random() - 0.5) * shakeIntensity;
if (globalDistance.current <= 0.1) {
window.location.href = '/';
fearState.registerCaught();
return;
}
}
const forwardVector = new THREE.Vector3();
camera.getWorldDirection(forwardVector);
const lookDirZ = forwardVector.z < 0 ? -1 : 1;
const calculatedZ = camera.position.z + (lookDirZ * globalDistance.current);
creature.position.set(0, 1.6, calculatedZ);
creature.lookAt(camera.position.x, creature.position.y, camera.position.z);
if (!hasTriggered) return;
const proximity = 1 - Math.max(0, Math.min(1, globalDistance.current / 32));
const jitterX = 1.0 + (Math.random() - 0.5) * 0.04 * proximity;
let jitterY = 1.0 + (Math.random() - 0.5) * 0.06 * proximity;
glitchCooldown.current -= delta;
if (glitchCooldown.current <= 0) {
if (Math.random() < 0.25 + proximity * 0.35) {
isGlitchSpiking.current = true;
glitchCooldown.current = 0.03 + Math.random() * 0.08;
} else {
isGlitchSpiking.current = false;
glitchCooldown.current = 0.08 + Math.random() * 0.4 * (1 - proximity * 0.7);
}
}
if (isGlitchSpiking.current) {
const spike = 0.15 + Math.random() * 0.35;
jitterY += Math.random() > 0.5 ? spike : -spike * 0.6;
}
creature.scale.set(jitterX, jitterY, 1.0);
flickerCooldown.current -= delta;
if (flickerCooldown.current <= 0) {
if (creature.visible && Math.random() < 0.12 + proximity * 0.08) {
creature.visible = false;
flickerCooldown.current = 0.02 + Math.random() * 0.05;
} else {
creature.visible = true;
flickerCooldown.current = 0.05 + Math.random() * 0.3 * (1 - proximity * 0.5);
}
}
texture.offset.set(
(Math.random() - 0.5) * 0.025 * proximity,
(Math.random() - 0.5) * 0.025 * proximity
);
if (proximity > 0.2) {
creature.position.x += (Math.random() - 0.5) * 0.12 * proximity;
creature.position.y += (Math.random() - 0.5) * 0.06 * proximity;
}
});
return (
<mesh
ref={meshRef}
visible={finaleTriggered}
>
<planeGeometry args={[3.0, 4.8]} />
<meshStandardMaterial
map={texture}
transparent={true}
depthWrite={false}
side={THREE.DoubleSide}
onBeforeCompile={ShaderPatch}
emissive="#ffffff"
emissiveMap={texture}
emissiveIntensity={0.15}
/>
{finaleTriggered && (
<PositionalAudio
url="fear/snd/riser.mp3"
ref={audioRef}
distance={25}
loop={false}
autoplay={false}
/>
)}
</mesh>
);
}
@@ -0,0 +1,84 @@
@font-face {
font-family: 'VCR';
src: url('/fear/fonts/vcr.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
.finale-container {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0vh;
display: grid;
align-items: center;
align-content: center;
justify-content: center;
overflow: hidden;
/* filter: invert(100%); */
backdrop-filter: brightness(100%);
grid-auto-rows: 5vh;
/* grid-template-columns: 0; */
grid-template-rows: repeat(auto-fit, max-content);
user-select: none;
will-change: filter;
animation: invertFlicker 0.07s infinite alternate;
}
@keyframes invertFlicker {
0%,
43%,
45%,
88%,
92% {
filter: invert(0%) contrast(100%) brightness(100%);
backdrop-filter: brightness(100%) hue-rotate(0deg);
}
44%,
46%,
89%,
93%,
100% {
filter: invert(100%) contrast(300%) brightness(150%);
backdrop-filter: brightness(30%) hue-rotate(180deg) saturate(500%);
}
}
.finale-text {
font-family: 'VCR', sans-serif;
font-variant-numeric: tabular-nums;
letter-spacing: 0.1em;
height: 0px;
width: 100%;
color: rgb(255, 255, 255);
font-size: 8vh;
text-align: center;
white-space: nowrap;
}
.scanlines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 900;
background: repeating-linear-gradient(rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.3) 2px,
rgba(0, 0, 0, 0.3) 4px);
}
@@ -0,0 +1,56 @@
import { JSX, useEffect, useState } from "react"
import { fearState } from "../state"
import './finale-text.css';
const BLOCKS = [
"▀", "▂", "▃", "▄", "▅", "▆", "▇",
"█", "▉", "▊", "▋", "▌", "▍", "▎", "▏",
"▐", "░", "▒", "▓", "▔", "▕", "▖", "▗",
"▘", "▙", "▚", "▛", "▜", "▝", "▞", "▟"
];
export default function FinaleText() {
const [wasCaught, setWasCaught] = useState(fearState.wasCaught);
const [elements, setElements] = useState<JSX.Element[]>([]);
useEffect(() => {
const unsubscribe = fearState.subscribe(() => {
setWasCaught(fearState.wasCaught)
});
return () => unsubscribe();
}, []);
useEffect(() => {
if (!wasCaught)
return;
const interval = setInterval(() => {
if (Math.random() > 0.9) return;
const baseText = "bwaaaaaaaaa";
const corrupted = baseText
.split("")
.map((char) => (Math.random() > 0.98 ? BLOCKS[Math.floor(Math.random() * BLOCKS.length)] : char))
.join("");
setElements((prev) => [...prev.slice(-30),
<span className="finale-text" key={crypto.randomUUID()}>
{corrupted}
</span>
]);
}, 10);
return () => clearInterval(interval);
}, [wasCaught]);
if (!wasCaught) return null;
return (<>
<div className="finale-container">
{elements}
</div>
<div className="scanlines" />
</>)
}
+401
View File
@@ -0,0 +1,401 @@
import { useEffect, useRef, useState } from "react";
import { FEAR_SETTINGS, fearState } from "../state";
import { useTexture, PositionalAudio } from "@react-three/drei";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { ShaderPatch } from "../shader-patch";
interface DoorProps {
position: [number, number, number];
rotation: [number, number, number];
}
function Door({ position, rotation }: DoorProps) {
const [soundUrl, setSoundUrl] = useState<string | null>(null);
const currentSound = useRef<string | null>(null);
const steelTex = useTexture('fear/img/steel.png');
useEffect(() => {
const interval = setInterval(() => {
if (Math.random() < 0.02) {
const chosenSound = Math.random() < 0.5 ? "fear/snd/knock1.mp3" : "fear/snd/knock2.mp3";
setSoundUrl(chosenSound);
currentSound.current = chosenSound;
}
}, 5000);
return () => clearInterval(interval);
}, []);
const handleAudioEnded = () => {
setSoundUrl(null);
currentSound.current = null;
};
return (
<group position={position} rotation={rotation}>
{/* frame */}
<mesh position={[0, 2, -0.1]}>
<boxGeometry args={[2.4, 4.0, 0.2, 4, 4, 1]} />
<meshStandardMaterial map={steelTex} color="#8d8d8d" onBeforeCompile={ShaderPatch} />
</mesh>
{/* panel */}
<mesh position={[0, 1.95, -0.0]}>
<boxGeometry args={[2.1, 3.8, 0.1, 4, 4, 1]} />
<meshStandardMaterial map={steelTex} color="#4e4a4a" onBeforeCompile={ShaderPatch} />
</mesh>
{/* handle */}
<mesh position={[0.75, 1.8, .085]}>
<boxGeometry args={[0.3, 0.08, 0.1]} />
<meshStandardMaterial map={steelTex} color="#ffffff" onBeforeCompile={ShaderPatch} />
</mesh>
{soundUrl && (
<PositionalAudio
url={soundUrl}
distance={25}
loop={false}
autoplay={true}
onEnded={handleAudioEnded}
/>
)}
</group>
);
}
export default function Hallway() {
const [width, setWidth] = useState(fearState.currentWidth);
const [floorTex, wallTex, rustWallTex, rustFloorTex] = useTexture([
'fear/img/concrete-floor.png',
'fear/img/concrete-wall.png',
'fear/img/rust.png',
'fear/img/rust.png'
]);
useEffect(() => {
[floorTex, wallTex, rustWallTex, rustFloorTex].forEach((tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.minFilter = tex.magFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
});
}, [floorTex, wallTex, rustWallTex, rustFloorTex]);
const segmentPool = [0, 1, 2, 3, 4];
const segmentCount = segmentPool.length;
const lightRefs = useRef<(THREE.PointLight | null)[]>([]);
const matRefs = useRef<(THREE.MeshStandardMaterial | null)[]>([]);
const lightState = useRef<'normal' | 'flickering' | 'dead'>('normal');
const stateEndTime = useRef<number>(0);
const nextEventTime = useRef<number>(5);
const segmentsRef = useRef<THREE.Group[]>([]);
const wallMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
const floorMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
const pipeMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
const bracketMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
wallMaterialsRef.current = [];
floorMaterialsRef.current = [];
pipeMaterialsRef.current = [];
bracketMaterialsRef.current = [];
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
useEffect(() => {
const unsubscribe = fearState.subscribe(() => {
setWidth(fearState.currentWidth);
setIsRustActive(fearState.isRustActive);
});
return () => unsubscribe();
}, []);
useFrame((state, delta) => {
const time = state.clock.elapsedTime;
/*
lights
*/
let intensity1 = 0.85 + Math.sin(time * 2) * 0.03;
if (time > nextEventTime.current && lightState.current === 'normal') {
lightState.current = 'flickering';
stateEndTime.current = time + 1.5 + Math.random() * 2;
}
if (lightState.current === 'flickering') {
if (time > stateEndTime.current) {
if (Math.random() > 0.4) {
lightState.current = 'dead';
stateEndTime.current = time + 1.0 + Math.random() * 2.5;
} else {
lightState.current = 'normal';
nextEventTime.current = time + 10 + Math.random() * 20;
}
} else {
const baseWave = Math.sin(time * 45) * 0.4 + Math.sin(time * 90) * 0.3;
intensity1 = 0.5 + baseWave;
if (Math.sin(time * 150) + Math.cos(time * 220) > 1.2) intensity1 *= Math.random() > 0.5 ? 0.0 : 0.15;
}
}
if (lightState.current === 'dead') {
if (time > stateEndTime.current) {
lightState.current = 'normal';
nextEventTime.current = time + 12 + Math.random() * 15;
} else {
intensity1 = Math.random() > 0.98 ? 0.08 : 0.0;
}
}
/*
objects
*/
const length = FEAR_SETTINGS.HALLWAY_LENGTH;
const playerSegmentZ = Math.floor(state.camera.position.z / length);
const horizontalTexRepeat = width / FEAR_SETTINGS.HALLWAY_WIDTH;
floorTex.repeat.set(horizontalTexRepeat, 10);
wallTex.repeat.set(10, 1);
rustWallTex.repeat.set(10, 1);
rustFloorTex.repeat.set(horizontalTexRepeat, 10);
floorTex.needsUpdate = true;
wallTex.needsUpdate = true;
rustWallTex.needsUpdate = true;
rustFloorTex.needsUpdate = true;
let closestPoolIndex = 0;
let minDistance = Infinity;
segmentsRef.current.forEach((segGroup, poolIndex) => {
if (!segGroup) return;
let segmentZIndex = poolIndex - Math.floor(segmentCount / 2) + playerSegmentZ;
segGroup.position.z = segmentZIndex * length;
const distance = Math.abs(segGroup.position.z - state.camera.position.z);
if (distance < minDistance) {
minDistance = distance;
closestPoolIndex = poolIndex;
}
const leftWallGroup = segGroup.getObjectByName("left-wall-group");
if (leftWallGroup) leftWallGroup.position.x = -width / 2;
const rightWallGroup = segGroup.getObjectByName("right-wall-group");
if (rightWallGroup) rightWallGroup.position.x = width / 2;
const floorMesh = segGroup.getObjectByName("floor-mesh");
if (floorMesh) floorMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
const ceilingMesh = segGroup.getObjectByName("ceiling-mesh");
if (ceilingMesh) ceilingMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
for (let i = 0; i < 3; i++) {
const pipe = segGroup.getObjectByName(`pipe-${i}`);
if (pipe) pipe.position.x = -width / 2 + 0.4 + (i * 0.20);
}
const bracketGroup = segGroup.getObjectByName("brackets-group");
if (bracketGroup) {
bracketGroup.children.forEach(b => {
b.position.x = -width / 2 + 0.6;
});
}
});
/*
dyn light
*/
segmentPool.forEach((poolIndex) => {
const light = lightRefs.current[poolIndex];
const mat = matRefs.current[poolIndex];
if (poolIndex === closestPoolIndex) {
if (light) light.intensity = intensity1 * 1.2;
if (mat) {
mat.emissiveIntensity = intensity1 * 2.5;
if (lightState.current !== 'normal') mat.emissive.setHSL(0.07, 0.4, Math.min(intensity1, 0.7));
else mat.emissive.setHex(0xa8a1a1);
}
} else {
if (light) light.intensity = 0.9;
if (mat) {
mat.emissiveIntensity = 0.8;
mat.emissive.setHex(0xa8a1a1);
}
}
});
/*
materials
*/
const updateMaterials = (materials: THREE.MeshStandardMaterial[], defaultTex: THREE.Texture, targetRustTex: THREE.Texture, activeColor: string, defaultColor: string, activeRough: number, defaultRough: number, activeMetal: number, defaultMetal: number) => {
materials.forEach(mat => {
if (!mat) return;
const targetTex = isRustActive ? targetRustTex : defaultTex;
if (mat.map !== targetTex) {
mat.map = targetTex;
mat.needsUpdate = true;
}
mat.color.set(isRustActive ? activeColor : defaultColor);
mat.roughness = isRustActive ? activeRough : defaultRough;
mat.metalness = isRustActive ? activeMetal : defaultMetal;
});
};
updateMaterials(wallMaterialsRef.current, wallTex, rustWallTex, "#c5c0be", "#ffffff", 0.95, 0.7, 0.05, 0.1);
updateMaterials(floorMaterialsRef.current, floorTex, rustFloorTex, "#cabdb9", "#ffffff", 0.95, 0.8, 0.05, 0.2);
pipeMaterialsRef.current.forEach(mat => {
if (!mat) return;
mat.color.set(isRustActive ? "#3d1b0f" : "#a5aca8");
mat.roughness = isRustActive ? 0.95 : 0.0;
mat.metalness = isRustActive ? 0.05 : 0.4;
});
bracketMaterialsRef.current.forEach(mat => {
if (!mat) return;
mat.color.set(isRustActive ? "#1b0b05" : "#a5aca8");
mat.roughness = isRustActive ? 0.95 : 0.0;
mat.metalness = isRustActive ? 0.05 : 0.4;
});
});
return (
<>
{segmentPool.map((poolIndex) => (
<group
key={poolIndex}
ref={(el) => { if (el) segmentsRef.current[poolIndex] = el; }}
position={[0, 0, 0]}
>
{/* lights */}
<group position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.1, -FEAR_SETTINGS.HALLWAY_LENGTH / 4]}>
<pointLight
ref={(el) => { lightRefs.current[poolIndex] = el; }}
intensity={0.9}
distance={15}
color="#a8a1a1"
/>
<mesh position={[0, 0.09, 0]}>
<boxGeometry args={[0.3, 0.01, 0.3]} />
<meshStandardMaterial
ref={(el) => { matRefs.current[poolIndex] = el; }}
color="#111111"
emissive="#a8a1a1"
emissiveIntensity={0.8}
roughness={0.9}
onBeforeCompile={ShaderPatch}
/>
</mesh>
</group>
{/* floor */}
<mesh
name="floor-mesh"
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH, 4, 10]} />
<meshStandardMaterial
ref={(el) => { if (el) floorMaterialsRef.current.push(el); }}
map={floorTex}
onBeforeCompile={ShaderPatch}
/>
</mesh>
{/* ceiling */}
<mesh
name="ceiling-mesh"
rotation={[Math.PI / 2, 0, 0]}
position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH, 4, 10]} />
<meshStandardMaterial
ref={(el) => { if (el) floorMaterialsRef.current.push(el); }}
map={floorTex}
onBeforeCompile={ShaderPatch}
/>
</mesh>
{/* left wall */}
<group name="left-wall-group">
<mesh rotation={[0, Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT, 10, 4]} />
<meshStandardMaterial
ref={(el) => { if (el) wallMaterialsRef.current.push(el); }}
map={wallTex}
onBeforeCompile={ShaderPatch}
/>
</mesh>
{!isRustActive && (
<>
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.25]} rotation={[0, Math.PI / 2, 0]} />
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.85]} rotation={[0, Math.PI / 2, 0]} />
</>
)}
</group>
{/* right wall */}
<group name="right-wall-group">
<mesh rotation={[0, -Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT, 10, 4]} />
<meshStandardMaterial
ref={(el) => { if (el) wallMaterialsRef.current.push(el); }}
map={wallTex}
onBeforeCompile={ShaderPatch}
/>
</mesh>
{!isRustActive && (
<Door position={[-0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.65]} rotation={[0, -Math.PI / 2, 0]} />
)}
</group>
{/* pipes */}
{Array.from({ length: 3 }).map((_, idx) => (
<mesh
key={idx}
name={`pipe-${idx}`}
rotation={[Math.PI / 2, 0, 0]}
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.4 + (idx * 0.20), FEAR_SETTINGS.HALLWAY_HEIGHT - 0.2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
>
<cylinderGeometry args={[0.06, 0.06, FEAR_SETTINGS.HALLWAY_LENGTH, 4]} />
<meshStandardMaterial
ref={(el) => el && pipeMaterialsRef.current.push(el)}
color="#a5aca8"
roughness={0.0}
metalness={0.4}
onBeforeCompile={ShaderPatch}
/>
</mesh>
))}
{/* brackets */}
<group name="brackets-group">
{Array.from({ length: 5 }).map((_, idx) => {
const zOffset = -(idx * 8 + 4);
return (
<mesh
key={`bracket-${idx}`}
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.6, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.15, zOffset]}
>
<boxGeometry args={[0.7, 0.3, 0.15]} />
<meshStandardMaterial
ref={(el) => el && bracketMaterialsRef.current.push(el)}
color="#a5aca8"
roughness={0.0}
metalness={0.4}
onBeforeCompile={ShaderPatch}
/>
</mesh>
);
})}
</group>
</group>
))}
</>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react";
import { FEAR_SETTINGS, fearState } from "../state";
import { PointerLockControls } from "@react-three/drei";
import * as THREE from "three";
const forward = new THREE.Vector3();
const side = new THREE.Vector3();
const viewDirection = new THREE.Vector3();
const targetDest = new THREE.Vector3();
const playerRoot = new THREE.Vector3(0, FEAR_SETTINGS.PLAYER_HEIGHT, 0);
const targetVelocity = new THREE.Vector3();
const currentVelocity = new THREE.Vector3();
function usePlayerControls() {
const keys = useRef({ Forward: false, Backward: false, Left: false, Right: false });
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = true;
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = true;
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = true;
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = true;
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = false;
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = false;
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = false;
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = false;
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
return keys.current;
}
export default function Player() {
const { camera } = useThree();
const controls = usePlayerControls();
const flashlightRef = useRef<THREE.SpotLight>(null);
const movementCounter = useRef<number>(0);
const bobIntensity = useRef<number>(0);
const confirmedSegment = useRef<number>(0);
const hasTriggeredThisSegment = useRef<boolean>(false);
const footstepAudio = useRef<HTMLAudioElement[]>([]);
const hasStepped = useRef<boolean>(false);
useEffect(() => {
playerRoot.set(camera.position.x, FEAR_SETTINGS.PLAYER_HEIGHT, camera.position.z);
footstepAudio.current = Array.from({ length: 6 }, (_, i) => {
const audio = new Audio(`fear/snd/footstep${i + 1}.mp3`);
audio.volume = 0.4;
return audio;
});
}, []);
const playRandomFootstep = () => {
if (footstepAudio.current.length === 0) return;
const randomIndex = Math.floor(Math.random() * footstepAudio.current.length);
const audio = footstepAudio.current[randomIndex];
audio.currentTime = 0;
audio.play().catch((err) => {
console.warn("Footstep playback blocked by browser autocomplete/interaction rules.", err);
});
};
useFrame((state, delta) => {
const dt = Math.min(delta, 0.1);
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
side.crossVectors(forward, THREE.Object3D.DEFAULT_UP).normalize();
const moveForward = Number(controls.Forward) - Number(controls.Backward);
const moveSide = Number(controls.Right) - Number(controls.Left);
targetVelocity.set(0, 0, 0);
if (moveForward !== 0) targetVelocity.addScaledVector(forward, moveForward);
if (moveSide !== 0) targetVelocity.addScaledVector(side, moveSide);
if (targetVelocity.lengthSq() > 0)
targetVelocity.normalize().multiplyScalar(FEAR_SETTINGS.PLAYER_SPEED);
currentVelocity.lerp(targetVelocity, 10 * dt);
playerRoot.x += currentVelocity.x * dt;
playerRoot.z += currentVelocity.z * dt;
const minX = -fearState.currentWidth / 2 + FEAR_SETTINGS.WALL_BUFFER;
const maxX = fearState.currentWidth / 2 - FEAR_SETTINGS.WALL_BUFFER;
playerRoot.x = THREE.MathUtils.clamp(playerRoot.x, minX, maxX);
const isMoving = controls.Forward || controls.Backward || controls.Left || controls.Right;
bobIntensity.current = THREE.MathUtils.lerp(bobIntensity.current, isMoving ? 1 : 0, 8 * dt);
if (isMoving)
movementCounter.current += dt * 12;
const sinWave = Math.sin(movementCounter.current);
const moveBobY = sinWave * 0.06 * bobIntensity.current;
const moveBobX = Math.cos(movementCounter.current / 2) * 0.04 * bobIntensity.current;
if (isMoving && sinWave < -0.9) {
if (!hasStepped.current) {
playRandomFootstep();
hasStepped.current = true;
}
} else if (sinWave > 0) {
hasStepped.current = false;
}
const breatheTime = state.clock.elapsedTime * 1.8;
const breatheBobY = Math.sin(breatheTime) * 0.03 * (1 - bobIntensity.current * 0.5);
camera.position.copy(playerRoot);
camera.position.y += moveBobY + breatheBobY;
camera.position.addScaledVector(side, moveBobX);
if (flashlightRef.current) {
flashlightRef.current.position.lerp(camera.position, 7 * dt);
camera.getWorldDirection(viewDirection);
targetDest
.copy(camera.position)
.addScaledVector(viewDirection, 10);
flashlightRef.current.target.position.lerp(targetDest, 12 * dt);
flashlightRef.current.target.updateMatrixWorld();
flashlightRef.current.intensity =
FEAR_SETTINGS.FLASHLIGHT_INTENSITY_BASE +
Math.sin(state.clock.elapsedTime * 30) * 0.15 * Math.cos(state.clock.elapsedTime * 3);
}
const length = FEAR_SETTINGS.HALLWAY_LENGTH;
const absoluteZ = -playerRoot.z;
const rawSegmentIndex = Math.floor(absoluteZ / length);
const progressZ = ((absoluteZ % length) + length) % length / length;
if (rawSegmentIndex > confirmedSegment.current && progressZ > 0.25) {
if (!hasTriggeredThisSegment.current) {
fearState.registerLoop('forward');
hasTriggeredThisSegment.current = true;
}
confirmedSegment.current = rawSegmentIndex;
}
else if (rawSegmentIndex < confirmedSegment.current && progressZ < 0.75) {
if (!hasTriggeredThisSegment.current) {
fearState.registerLoop('backward');
hasTriggeredThisSegment.current = true;
}
confirmedSegment.current = rawSegmentIndex;
}
if (rawSegmentIndex === confirmedSegment.current && progressZ > 0.35 && progressZ < 0.65) {
hasTriggeredThisSegment.current = false;
}
});
return (
<>
<PointerLockControls />
<spotLight
ref={flashlightRef}
distance={25}
angle={0.35}
penumbra={0.8}
intensity={0}
color="#fffaed"
decay={2}
castShadow
shadow-bias={-0.001}
/>
</>
);
}
+57
View File
@@ -0,0 +1,57 @@
export function ShaderPatch(shader: { vertexShader: string, fragmentShader: string, uniforms: Object }) {
shader.vertexShader = `
varying float vDepth;
#ifdef USE_MAP
varying vec2 vAffineUv;
#endif
${shader.vertexShader}
`;
shader.vertexShader = shader.vertexShader.replace(
`#include <project_vertex>`,
`
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
float precisionModifier = 200.0;
gl_Position.xy /= gl_Position.w;
gl_Position.xy = floor(gl_Position.xy * precisionModifier) / precisionModifier;
gl_Position.xy *= gl_Position.w;
vDepth = gl_Position.w;
#ifdef USE_MAP
vAffineUv = vMapUv * gl_Position.w;
#endif
`
);
shader.fragmentShader = `
varying float vDepth;
#ifdef USE_MAP
varying vec2 vAffineUv;
#endif
${shader.fragmentShader}
`;
shader.fragmentShader = shader.fragmentShader.replace(
`#include <map_fragment>`,
`
#ifdef USE_MAP
vec2 flatAffineUV = vAffineUv / max(vDepth, 0.001);
vec2 warpDiff = flatAffineUV - vMapUv;
float warpDist = length(warpDiff);
float maxDistortion = 0.25;
float falloff = maxDistortion / (maxDistortion + warpDist);
vec2 finalUV = vMapUv + (warpDiff * falloff);
vec4 texelColor = texture2D( map, finalUV );
diffuseColor *= texelColor;
#endif
`
);
}
+35 -5
View File
@@ -5,7 +5,18 @@ export const FEAR_SETTINGS = {
HALLWAY_WIDTH: 6,
HALLWAY_HEIGHT: 5,
PLAYER_HEIGHT: 3,
PLAYER_SPEED: 4,
FLASHLIGHT_INTENSITY_BASE: 8,
WALL_BUFFER: 0.6,
CREATURE_SPEED: 8,
EVENT_NARROW_LOOP_COUNT: 2,
EVENT_RUST_LOOP_COUNT: 4,
EVENT_FINALE_LOOP_COUNT: 5,
EVENT_FINALE_DURATION: 1,
TEST_MODE: false
};
const listeners = new Set<() => void>();
@@ -14,6 +25,9 @@ export const fearState = {
loopCount: 0,
currentWidth: FEAR_SETTINGS.HALLWAY_WIDTH,
isRustActive: false,
finaleTriggered: false,
wasCaught: false,
finaleProgression: 0,
subscribe(listener: () => void) {
listeners.add(listener);
@@ -25,20 +39,36 @@ export const fearState = {
},
update(delta: number) {
this.isRustActive = this.loopCount >= 3;
const targetWidth = this.loopCount >= 2 ? 2.5 : FEAR_SETTINGS.HALLWAY_WIDTH;
const targetWidth = this.loopCount >= FEAR_SETTINGS.EVENT_NARROW_LOOP_COUNT ? 2.5 : FEAR_SETTINGS.HALLWAY_WIDTH;
const newWidth = THREE.MathUtils.lerp(this.currentWidth, targetWidth, 2 * delta);
if (Math.abs(this.currentWidth - newWidth) > 0.001) {
this.currentWidth = newWidth;
this.emit();
}
if (this.wasCaught) {
if (this.finaleProgression < FEAR_SETTINGS.EVENT_FINALE_DURATION) {
this.finaleProgression = Math.min(this.finaleProgression + delta, FEAR_SETTINGS.EVENT_FINALE_DURATION);
} else {
window.location.href = '/';
}
}
this.emit();
},
registerLoop(direction: 'forward' | 'backward') {
this.loopCount += 1;
console.log(`Hallway looped ${direction}. Total loops: ${this.loopCount}`);
this.isRustActive = this.loopCount >= FEAR_SETTINGS.EVENT_RUST_LOOP_COUNT;
this.finaleTriggered = this.loopCount >= FEAR_SETTINGS.EVENT_FINALE_LOOP_COUNT;
this.emit();
},
registerCaught() {
this.wasCaught = true;
this.emit();
}
};
+17 -11
View File
@@ -68,10 +68,10 @@ function Scene() {
grassDryColor: '#495a17',
grassLushColor: '#255825',
grassCount: { value: 8000, min: 1000, max: 30000, step: 500 },
grassSize: { value: 0.65, min: 0.1, max: 2.0, step: 0.05 },
grassSize: { value: 0.85, min: 0.1, max: 2.0, step: 0.05 },
grassLOD: { value: 60, min: 10, max: 200, step: 5 },
grassBlades: { value: 3, min: 1, max: 4, step: 1 },
grassSegments: { value: 3, min: 1, max: 5, step: 1 },
grassBlades: { value: 3, min: 1, max: 5, step: 1 },
grassSegments: { value: 4, min: 1, max: 5, step: 1 },
grassLODStart: { value: 0.15, min: 0.0, max: 0.9, step: 0.05 },
grassLODExponent: { value: 1.8, min: 0.5, max: 3.0, step: 0.1 },
})
@@ -80,11 +80,17 @@ function Scene() {
return (<>
<Environment
files={'niko/hdr/sky.hdr'}
environmentIntensity={1}
environmentIntensity={0.85}
background
/>
<fogExp2 attach='fog' args={[0x9a9a9a, 0.01]} />
<fogExp2 attach='fog' args={[0xa3a5ba, 0.0125]} />
<ambientLight intensity={0.5} />
<directionalLight
position={[15, 25, 15]}
intensity={1}
/>
<Terrain
chunks={chunks}
@@ -123,9 +129,9 @@ function PostProcessing() {
<Vignette />
<Noise opacity={0.05} />
<Bloom
intensity={2}
luminanceThreshold={0.5}
luminanceSmoothing={0.1}
intensity={0.8}
luminanceThreshold={0.4}
luminanceSmoothing={0.5}
/>
<SMAA />
<HueSaturation saturation={0.3} />
@@ -145,11 +151,11 @@ export default function Seal() {
<Canvas
shadows
camera={{ position: [0, 5, 15], fov: 50, far: 100 }}
gl={{ antialias: true }}
gl={{ antialias: false, powerPreference: "high-performance" }}
className='canvas'
>
<AmbientSound url="niko/snd/wind.mp3" volume={0.4}/>
<AmbientSound url="niko/snd/birds.mp3" volume={0.1}/>
<AmbientSound url="niko/snd/wind.mp3" volume={0.4} />
<AmbientSound url="niko/snd/birds.mp3" volume={0.1} />
<Scene />
<PostProcessing />
+21 -19
View File
@@ -173,24 +173,27 @@ export default function Grass({
dummy.rotation.x = (Math.random() - 0.5) * 0.2;
dummy.rotation.z = (Math.random() - 0.5) * 0.2;
const baseScale = grassSize + Math.random() * grassSize * 0.5;
const heightMult = 0.5 + Math.random() * 1.0;
dummy.scale.set(baseScale, baseScale * heightMult, baseScale);
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
const t = (noiseVal + 1) / 2;
const randomInternal = (Math.random() - 0.5) * 0.2;
const finalT = Math.max(0, Math.min(1, t + randomInternal));
color.lerpColors(dryColorObj, lushColorObj, finalT);
meshRef.current.setColorAt(instanceIndex, color);
const heightNoise = noise2D(globalX * 0.08, globalZ * 0.08);
const macroHeight = (heightNoise + 1.0) * 0.5; // 0..1
const microNoise = noise2D(globalX * 0.3, globalZ * 0.3);
const microHeight = (microNoise + 1.0) * 0.25; // 0..0.5
const perBladeRandom = Math.random() * 0.4;
const grassWidth = grassSize * (0.7 + Math.random() * 0.5);
const grassHeight = grassSize * (0.4 + macroHeight * 0.8 + microHeight + perBladeRandom);
dummy.scale.set(grassWidth, grassHeight, grassWidth);
dummy.updateMatrix();
meshRef.current.setMatrixAt(instanceIndex, dummy.matrix);
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
const t = (noiseVal + 1) / 2;
const randomInternal = (Math.random() - 0.5) * 0.2;
const finalT = Math.max(0, Math.min(1, t + randomInternal));
color.lerpColors(dryColorObj, lushColorObj, finalT);
meshRef.current.setColorAt(instanceIndex, color);
instanceIndex++;
}
meshRef.current.count = instanceIndex;
@@ -224,6 +227,7 @@ export default function Grass({
shader.vertexShader = `
uniform float uTime;
varying vec2 vGrassUv;
varying vec3 vWorldPos;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
@@ -233,12 +237,10 @@ export default function Grass({
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
@@ -246,23 +248,21 @@ export default function Grass({
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for(int i = 0; i < 4; i++) {
value += amplitude * noise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
${shader.vertexShader}
`;
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`
#include <begin_vertex>
vGrassUv = uv;
${grassVert}
`
);
@@ -270,8 +270,10 @@ export default function Grass({
shader.fragmentShader = `
uniform float uTime;
varying vec2 vGrassUv;
varying vec3 vWorldPos;
${shader.fragmentShader}
`;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <color_fragment>',
`
+7 -1
View File
@@ -12,13 +12,19 @@ const SealCube = forwardRef<Mesh>((props, ref) => {
if (meshRef.current) {
meshRef.current.rotation.x += delta * 0.5;
meshRef.current.rotation.y += delta * 0.5;
meshRef.current.position.y = 3 + Math.sin(state.clock.getElapsedTime() * 1) * 0.15;
}
});
return (
<mesh ref={meshRef} position={[0, 3, 0]} castShadow receiveShadow>
<boxGeometry args={[0.85, 0.85, 0.85]} />
<meshBasicMaterial map={texture} depthWrite={true} />
<meshStandardMaterial
map={texture}
roughness={0.4}
metalness={0.1}
envMapIntensity={1.2}
/>
</mesh>
);
});
@@ -1,13 +1,31 @@
float ao = smoothstep(0.0, 0.7, vGrassUv.y);
ao = mix(0.05, 1.0, ao);
ao = mix(0.05, 1.0, pow(ao, 1.6));
vec3 rootColor = diffuseColor.rgb * 0.4;
vec3 tipColor = diffuseColor.rgb * 1.5;
vec3 rootColor = diffuseColor.rgb * 0.15;
vec3 midColor = diffuseColor.rgb;
vec3 tipColor = diffuseColor.rgb * 1.3 + vec3(0.06, 0.08, 0.0);
vec3 grassColor = mix(rootColor, tipColor, vGrassUv.y);
grassColor *= ao;
float heightParam = vGrassUv.y;
vec3 grassColor;
if (heightParam < 0.4) {
float t = smoothstep(0.0, 0.4, heightParam);
grassColor = mix(rootColor, midColor, t);
} else {
float t = smoothstep(0.4, 1.0, heightParam);
grassColor = mix(midColor, tipColor, t);
}
float translucency = pow(vGrassUv.y, 2.0) * 0.5;
grassColor += diffuseColor.rgb * translucency;
vec3 viewDir = normalize(cameraPosition - vWorldPos);
vec3 lightDir = normalize(vec3(15.0, 25.0, 15.0));
diffuseColor.rgb = grassColor;
float VdotL = max(0.0, dot(viewDir, -lightDir));
float sss = pow(VdotL, 3.0) * smoothstep(0.2, 0.9, vGrassUv.y);
vec3 sssColor = diffuseColor.rgb * vec3(0.6, 1.0, 0.15) * 1.8;
grassColor += sssColor * sss * 2.0;
float NdotV = 1.0 - max(0.0, dot(normalize(vNormal), viewDir));
float rim = pow(NdotV, 3.0) * smoothstep(0.3, 1.0, vGrassUv.y) * 0.15;
grassColor += vec3(0.3, 0.5, 0.1) * rim;
diffuseColor.rgb = grassColor * ao;
@@ -1,3 +1,5 @@
vGrassUv = uv;
vec4 worldPos = modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float gx = worldPos.x;
float gz = worldPos.z;
@@ -8,7 +10,6 @@ float windSpeed = 1.5;
float windTime = uTime * windSpeed;
vec2 windSamplePos = (worldPos.xz * 0.05) - (mainWindDir * windTime * 0.2);
float windBase = fbm(windSamplePos * 0.8) * 0.4 + 0.2;
float gustNoise = fbm(windSamplePos * 0.4);
float gust = pow(gustNoise, 3.0) * 1.8;
@@ -20,19 +21,32 @@ float spring = sin(uTime * 2.0 + phase) * 0.06 + sin(uTime * 4.5 + phase * 1.5)
float angleNoise = fbm(windSamplePos * 2.0 + uTime * 0.1) - 0.5;
vec2 windDir = normalize(mainWindDir + vec2(-mainWindDir.y, mainWindDir.x) * angleNoise * 0.4);
float taperFactor = pow(uv.y, 4.0);
float taper = 1.0 - taperFactor * 0.6;
// taper (fade)
float taperFactor = uv.y * uv.y * uv.y;
float taper = 1.0 - taperFactor * 0.85;
transformed.x *= taper;
transformed.z *= taper;
// curve
float curveVal = fbm(vec2(gx, gz) * 0.5);
float curveStrength = 2.0 + curveVal * 2.0;
float curveStrength = 1.5 + curveVal * 2.5;
float curveAmount = uv.y * uv.y * curveStrength;
vec2 curveDir = normalize(vec2(curveVal, fbm(vec2(gz, gx))) - 0.5);
transformed.x += curveAmount * curveDir.x * 0.5;
transformed.z += curveAmount * curveDir.y * 0.5;
transformed.x += curveAmount * curveDir.x * 0.4;
transformed.z += curveAmount * curveDir.y * 0.4;
// sway
float swayAmount = (totalWind + spring) * uv.y * uv.y;
transformed.x += swayAmount * windDir.x;
transformed.z += swayAmount * windDir.y;
transformed.y -= abs(swayAmount) * 0.2;
// normal comp
vec2 totalBend = curveDir * curveAmount * 0.4 + windDir * swayAmount;
float bendMag = length(totalBend);
vec3 bentNormal = normalize(vec3(-totalBend.x * 0.5, 1.0, -totalBend.y * 0.5));
// normal mix
objectNormal = normalize(mix(vec3(0.0, 1.0, 0.0), bentNormal, uv.y));
vWorldPos = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz;