Initial commit

This commit is contained in:
2026-01-30 03:53:38 +07:00
commit 42abc57430
85 changed files with 8922 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import setCharacter from "./utils/character";
import setLighting from "./utils/lighting";
import { useLoading } from "../../context/LoadingProvider";
import handleResize from "./utils/resizeUtils";
import {
handleMouseMove,
handleTouchEnd,
handleHeadRotation,
handleTouchMove,
} from "./utils/mouseUtils";
import setAnimations from "./utils/animationUtils";
import { setProgress } from "../Loading";
const Scene = () => {
const canvasDiv = useRef<HTMLDivElement | null>(null);
const hoverDivRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef(new THREE.Scene());
const { setLoading } = useLoading();
const [character, setChar] = useState<THREE.Object3D | null>(null);
useEffect(() => {
if (canvasDiv.current) {
let rect = canvasDiv.current.getBoundingClientRect();
let container = { width: rect.width, height: rect.height };
const aspect = container.width / container.height;
const scene = sceneRef.current;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setSize(container.width, container.height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
canvasDiv.current.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(14.5, aspect, 0.1, 1000);
camera.position.z = 10;
camera.position.set(0, 13.1, 24.7);
camera.zoom = 1.1;
camera.updateProjectionMatrix();
let headBone: THREE.Object3D | null = null;
let screenLight: any | null = null;
let mixer: THREE.AnimationMixer;
const clock = new THREE.Clock();
const light = setLighting(scene);
let progress = setProgress((value) => setLoading(value));
const { loadCharacter } = setCharacter(renderer, scene, camera);
loadCharacter().then((gltf) => {
if (gltf) {
const animations = setAnimations(gltf);
hoverDivRef.current && animations.hover(gltf, hoverDivRef.current);
mixer = animations.mixer;
let character = gltf.scene;
setChar(character);
scene.add(character);
headBone = character.getObjectByName("spine006") || null;
screenLight = character.getObjectByName("screenlight") || null;
progress.loaded().then(() => {
setTimeout(() => {
light.turnOnLights();
animations.startIntro();
}, 2500);
});
window.addEventListener("resize", () =>
handleResize(renderer, camera, canvasDiv, character)
);
}
});
let mouse = { x: 0, y: 0 },
interpolation = { x: 0.1, y: 0.2 };
const onMouseMove = (event: MouseEvent) => {
handleMouseMove(event, (x, y) => (mouse = { x, y }));
};
let debounce: number | undefined;
const onTouchStart = (event: TouchEvent) => {
const element = event.target as HTMLElement;
debounce = setTimeout(() => {
element?.addEventListener("touchmove", (e: TouchEvent) =>
handleTouchMove(e, (x, y) => (mouse = { x, y }))
);
}, 200);
};
const onTouchEnd = () => {
handleTouchEnd((x, y, interpolationX, interpolationY) => {
mouse = { x, y };
interpolation = { x: interpolationX, y: interpolationY };
});
};
document.addEventListener("mousemove", (event) => {
onMouseMove(event);
});
const landingDiv = document.getElementById("landingDiv");
if (landingDiv) {
landingDiv.addEventListener("touchstart", onTouchStart);
landingDiv.addEventListener("touchend", onTouchEnd);
}
const animate = () => {
requestAnimationFrame(animate);
if (headBone) {
handleHeadRotation(
headBone,
mouse.x,
mouse.y,
interpolation.x,
interpolation.y,
THREE.MathUtils.lerp
);
light.setPointLight(screenLight);
}
const delta = clock.getDelta();
if (mixer) {
mixer.update(delta);
}
renderer.render(scene, camera);
};
animate();
return () => {
clearTimeout(debounce);
scene.clear();
renderer.dispose();
window.removeEventListener("resize", () =>
handleResize(renderer, camera, canvasDiv, character!)
);
if (canvasDiv.current) {
canvasDiv.current.removeChild(renderer.domElement);
}
if (landingDiv) {
document.removeEventListener("mousemove", onMouseMove);
landingDiv.removeEventListener("touchstart", onTouchStart);
landingDiv.removeEventListener("touchend", onTouchEnd);
}
};
}
}, []);
return (
<>
<div className="character-container">
<div className="character-model" ref={canvasDiv}>
<div className="character-rim"></div>
<div className="character-hover" ref={hoverDivRef}></div>
</div>
</div>
</>
);
};
export default Scene;

View File

View File

@@ -0,0 +1,7 @@
import Scene from "./Scene";
const CharacterModel = () => {
return <Scene />;
};
export default CharacterModel;

View File

@@ -0,0 +1,118 @@
import * as THREE from "three";
import { GLTF } from "three-stdlib";
import { eyebrowBoneNames, typingBoneNames } from "../../../data/boneData";
const setAnimations = (gltf: GLTF) => {
let character = gltf.scene;
let mixer = new THREE.AnimationMixer(character);
if (gltf.animations) {
const introClip = gltf.animations.find(
(clip) => clip.name === "introAnimation"
);
const introAction = mixer.clipAction(introClip!);
introAction.setLoop(THREE.LoopOnce, 1);
introAction.clampWhenFinished = true;
introAction.play();
const clipNames = ["key1", "key2", "key5", "key6"];
clipNames.forEach((name) => {
const clip = THREE.AnimationClip.findByName(gltf.animations, name);
if (clip) {
const action = mixer?.clipAction(clip);
action!.play();
action!.timeScale = 1.2;
} else {
console.error(`Animation "${name}" not found`);
}
});
let typingAction: THREE.AnimationAction | null = null;
typingAction = createBoneAction(gltf, mixer, "typing", typingBoneNames);
if (typingAction) {
typingAction.enabled = true;
typingAction.play();
typingAction.timeScale = 1.2;
}
}
function startIntro() {
const introClip = gltf.animations.find(
(clip) => clip.name === "introAnimation"
);
const introAction = mixer.clipAction(introClip!);
introAction.clampWhenFinished = true;
introAction.reset().play();
setTimeout(() => {
const blink = gltf.animations.find((clip) => clip.name === "Blink");
mixer.clipAction(blink!).play().fadeIn(0.5);
}, 2500);
}
function hover(gltf: GLTF, hoverDiv: HTMLDivElement) {
let eyeBrowUpAction = createBoneAction(
gltf,
mixer,
"browup",
eyebrowBoneNames
);
let isHovering = false;
if (eyeBrowUpAction) {
eyeBrowUpAction.setLoop(THREE.LoopOnce, 1);
eyeBrowUpAction.clampWhenFinished = true;
eyeBrowUpAction.enabled = true;
}
const onHoverFace = () => {
if (eyeBrowUpAction && !isHovering) {
isHovering = true;
eyeBrowUpAction.reset();
eyeBrowUpAction.enabled = true;
eyeBrowUpAction.setEffectiveWeight(4);
eyeBrowUpAction.fadeIn(0.5).play();
}
};
const onLeaveFace = () => {
if (eyeBrowUpAction && isHovering) {
isHovering = false;
eyeBrowUpAction.fadeOut(0.6);
}
};
if (!hoverDiv) return;
hoverDiv.addEventListener("mouseenter", onHoverFace);
hoverDiv.addEventListener("mouseleave", onLeaveFace);
return () => {
hoverDiv.removeEventListener("mouseenter", onHoverFace);
hoverDiv.removeEventListener("mouseleave", onLeaveFace);
};
}
return { mixer, startIntro, hover };
};
const createBoneAction = (
gltf: GLTF,
mixer: THREE.AnimationMixer,
clip: string,
boneNames: string[]
): THREE.AnimationAction | null => {
const AnimationClip = THREE.AnimationClip.findByName(gltf.animations, clip);
if (!AnimationClip) {
console.error(`Animation "${clip}" not found in GLTF file.`);
return null;
}
const filteredClip = filterAnimationTracks(AnimationClip, boneNames);
return mixer.clipAction(filteredClip);
};
const filterAnimationTracks = (
clip: THREE.AnimationClip,
boneNames: string[]
): THREE.AnimationClip => {
const filteredTracks = clip.tracks.filter((track) =>
boneNames.some((boneName) => track.name.includes(boneName))
);
return new THREE.AnimationClip(
clip.name + "_filtered",
clip.duration,
filteredTracks
);
};
export default setAnimations;

View File

@@ -0,0 +1,62 @@
import * as THREE from "three";
import { DRACOLoader, GLTF, GLTFLoader } from "three-stdlib";
import { setCharTimeline, setAllTimeline } from "../../utils/GsapScroll";
import { decryptFile } from "./decrypt";
const setCharacter = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.PerspectiveCamera
) => {
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("/draco/");
loader.setDRACOLoader(dracoLoader);
const loadCharacter = () => {
return new Promise<GLTF | null>(async (resolve, reject) => {
try {
const encryptedBlob = await decryptFile(
"/models/character.enc",
"Character3D#@"
);
const blobUrl = URL.createObjectURL(new Blob([encryptedBlob]));
let character: THREE.Object3D;
loader.load(
blobUrl,
async (gltf) => {
character = gltf.scene;
await renderer.compileAsync(character, camera, scene);
character.traverse((child: any) => {
if (child.isMesh) {
const mesh = child as THREE.Mesh;
child.castShadow = true;
child.receiveShadow = true;
mesh.frustumCulled = true;
}
});
resolve(gltf);
setCharTimeline(character, camera);
setAllTimeline();
character!.getObjectByName("footR")!.position.y = 3.36;
character!.getObjectByName("footL")!.position.y = 3.36;
dracoLoader.dispose();
},
undefined,
(error) => {
console.error("Error loading GLTF model:", error);
reject(error);
}
);
} catch (err) {
reject(err);
console.error(err);
}
});
};
return { loadCharacter };
};
export default setCharacter;

View File

@@ -0,0 +1,23 @@
async function generateAESKey(password: string): Promise<CryptoKey> {
const passwordBuffer = new TextEncoder().encode(password);
const hashedPassword = await crypto.subtle.digest("SHA-256", passwordBuffer);
return crypto.subtle.importKey(
"raw",
hashedPassword.slice(0, 32),
{ name: "AES-CBC" },
false,
["encrypt", "decrypt"]
);
}
export const decryptFile = async (
url: string,
password: string
): Promise<ArrayBuffer> => {
const response = await fetch(url);
const encryptedData = await response.arrayBuffer();
const iv = new Uint8Array(encryptedData.slice(0, 16));
const data = encryptedData.slice(16);
const key = await generateAESKey(password);
return crypto.subtle.decrypt({ name: "AES-CBC", iv }, key, data);
};

View File

@@ -0,0 +1,61 @@
import * as THREE from "three";
import { RGBELoader } from "three-stdlib";
import { gsap } from "gsap";
const setLighting = (scene: THREE.Scene) => {
const directionalLight = new THREE.DirectionalLight(0xc7a9ff, 0);
directionalLight.intensity = 0;
directionalLight.position.set(-0.47, -0.32, -1);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
scene.add(directionalLight);
const pointLight = new THREE.PointLight(0xc2a4ff, 0, 100, 3);
pointLight.position.set(3, 12, 4);
pointLight.castShadow = true;
scene.add(pointLight);
new RGBELoader()
.setPath("/models/")
.load("char_enviorment.hdr", function (texture) {
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = texture;
scene.environmentIntensity = 0;
scene.environmentRotation.set(5.76, 85.85, 1);
});
function setPointLight(screenLight: any) {
if (screenLight.material.opacity > 0.9) {
pointLight.intensity = screenLight.material.emissiveIntensity * 20;
} else {
pointLight.intensity = 0;
}
}
const duration = 2;
const ease = "power2.inOut";
function turnOnLights() {
gsap.to(scene, {
environmentIntensity: 0.64,
duration: duration,
ease: ease,
});
gsap.to(directionalLight, {
intensity: 1,
duration: duration,
ease: ease,
});
gsap.to(".character-rim", {
y: "55%",
opacity: 1,
delay: 0.2,
duration: 2,
});
}
return { setPointLight, turnOnLights };
};
export default setLighting;

View File

@@ -0,0 +1,82 @@
import * as THREE from "three";
export const handleMouseMove = (
event: MouseEvent,
setMousePosition: (x: number, y: number) => void
) => {
const mouseX = (event.clientX / window.innerWidth) * 2 - 1;
const mouseY = -(event.clientY / window.innerHeight) * 2 + 1;
setMousePosition(mouseX, mouseY);
};
export const handleTouchMove = (
event: TouchEvent,
setMousePosition: (x: number, y: number) => void
) => {
const mouseX = (event.touches[0].clientX / window.innerWidth) * 2 - 1;
const mouseY = -(event.touches[0].clientY / window.innerHeight) * 2 + 1;
setMousePosition(mouseX, mouseY);
};
export const handleTouchEnd = (
setMousePosition: (
x: number,
y: number,
interpolationX: number,
interpolationY: number
) => void
) => {
setTimeout(() => {
setMousePosition(0, 0, 0.03, 0.03);
setTimeout(() => {
setMousePosition(0, 0, 0.1, 0.2);
}, 1000);
}, 2000);
};
export const handleHeadRotation = (
headBone: THREE.Object3D,
mouseX: number,
mouseY: number,
interpolationX: number,
interpolationY: number,
lerp: (x: number, y: number, t: number) => number
) => {
if (!headBone) return;
if (window.scrollY < 200) {
const maxRotation = Math.PI / 6;
headBone.rotation.y = lerp(
headBone.rotation.y,
mouseX * maxRotation,
interpolationY
);
let minRotationX = -0.3;
let maxRotationX = 0.4;
if (mouseY > minRotationX) {
if (mouseY < maxRotationX) {
headBone.rotation.x = lerp(
headBone.rotation.x,
-mouseY - 0.5 * maxRotation,
interpolationX
);
} else {
headBone.rotation.x = lerp(
headBone.rotation.x,
-maxRotation - 0.5 * maxRotation,
interpolationX
);
}
} else {
headBone.rotation.x = lerp(
headBone.rotation.x,
-minRotationX - 0.5 * maxRotation,
interpolationX
);
}
} else {
if (window.innerWidth > 1024) {
headBone.rotation.x = lerp(headBone.rotation.x, -0.4, 0.03);
headBone.rotation.y = lerp(headBone.rotation.y, -0.3, 0.03);
}
}
};

View File

@@ -0,0 +1,26 @@
import * as THREE from "three";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { setCharTimeline, setAllTimeline } from "../../utils/GsapScroll";
export default function handleResize(
renderer: THREE.WebGLRenderer,
camera: THREE.PerspectiveCamera,
canvasDiv: React.RefObject<HTMLDivElement>,
character: THREE.Object3D
) {
if (!canvasDiv.current) return;
let canvas3d = canvasDiv.current.getBoundingClientRect();
const width = canvas3d.width;
const height = canvas3d.height;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const workTrigger = ScrollTrigger.getById("work");
ScrollTrigger.getAll().forEach((trigger) => {
if (trigger != workTrigger) {
trigger.kill();
}
});
setCharTimeline(character, camera);
setAllTimeline();
}