413 lines
14 KiB
HTML
413 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Three.js Spirits Szene</title>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.155.0/build/three.module.js",
|
|
"GLTFLoader": "https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/loaders/GLTFLoader.js",
|
|
"DRACOLoader": "https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/loaders/DRACOLoader.js"
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
#viewer {
|
|
position: absolute; top:50%; left:50%;
|
|
transform: translate(-50%,-50%);
|
|
width:1200px; height:800px;
|
|
max-width:100%; max-height:100%;
|
|
background:#000; border-radius:15px; overflow:hidden;
|
|
}
|
|
canvas { display:block; }
|
|
body { margin:0; overflow:hidden; background:#000; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="viewer"></div>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { GLTFLoader } from 'GLTFLoader';
|
|
import { DRACOLoader } from 'DRACOLoader';
|
|
import { EffectComposer } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/RenderPass.js';
|
|
import { ShaderPass } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/ShaderPass.js';
|
|
import { UnrealBloomPass }from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/UnrealBloomPass.js';
|
|
import { GammaCorrectionShader } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/shaders/GammaCorrectionShader.js';
|
|
import { VignetteShader } from "https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/shaders/VignetteShader.js";
|
|
|
|
// --- Szene / Kamera / Renderer ---
|
|
const scene = new THREE.Scene();
|
|
const ASPECT = 3/2, SCALE = 15;
|
|
const hw = SCALE/2, hh = (SCALE/ASPECT)/2;
|
|
const camera = new THREE.OrthographicCamera(-hw, hw, hh, -hh, 0.1, 1000);
|
|
camera.position.set(0, -14.424, 20);
|
|
camera.rotation.set(THREE.MathUtils.degToRad(55), 0, 0);
|
|
|
|
const container = document.getElementById('viewer');
|
|
const renderer = new THREE.WebGLRenderer({ antialias:true });
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
container.appendChild(renderer.domElement);
|
|
|
|
// Foliage-Shader
|
|
const foliageTexture = new THREE.TextureLoader().load('assets/sprites/foliage.png');
|
|
foliageTexture.colorSpace = THREE.SRGBColorSpace;
|
|
const FoliageOverlayShader = {
|
|
uniforms: {
|
|
'tDiffuse': { value: null },
|
|
'tFoliage': { value: foliageTexture },
|
|
'opacity': { value: 1.0 }
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform sampler2D tDiffuse;
|
|
uniform sampler2D tFoliage;
|
|
uniform float opacity;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vec4 base = texture2D(tDiffuse, vUv);
|
|
vec4 foliage = texture2D(tFoliage, vUv);
|
|
gl_FragColor = mix(base, vec4(foliage.rgb, base.a), foliage.a * opacity);
|
|
}
|
|
`
|
|
};
|
|
const foliageOverlayPass = new ShaderPass(FoliageOverlayShader);
|
|
|
|
// Postprocessing
|
|
const composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
composer.addPass(foliageOverlayPass);
|
|
composer.addPass(new UnrealBloomPass(
|
|
new THREE.Vector2(container.clientWidth, container.clientHeight),
|
|
0.8, 0.2, 0.4
|
|
));
|
|
const vignettePass = new ShaderPass(VignetteShader);
|
|
vignettePass.uniforms['offset'].value = 0.3;
|
|
vignettePass.uniforms['darkness'].value = 1.35;
|
|
composer.addPass(vignettePass);
|
|
composer.addPass(new ShaderPass(GammaCorrectionShader));
|
|
|
|
// Resize-Handler
|
|
function onResize(){
|
|
const W=container.clientWidth, H=container.clientHeight, winA=W/H;
|
|
let vw,vh,vx,vy;
|
|
if(winA>ASPECT){
|
|
vh=H; vw=H*ASPECT; vx=(W-vw)/2; vy=0;
|
|
} else {
|
|
vw=W; vh=W/ASPECT; vx=0; vy=(H-vh)/2;
|
|
}
|
|
renderer.setSize(W,H);
|
|
renderer.setViewport(vx,vy,vw,vh);
|
|
renderer.setScissor(vx,vy,vw,vh);
|
|
renderer.setScissorTest(true);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.2;
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
composer.setSize(W, H);
|
|
const dpr = window.devicePixelRatio || 1;
|
|
renderer.setPixelRatio(dpr);
|
|
composer.setPixelRatio(dpr);
|
|
camera.updateProjectionMatrix();
|
|
}
|
|
window.addEventListener('resize',onResize);
|
|
onResize();
|
|
|
|
// --- HDRI Environment ---
|
|
const texLoader = new THREE.TextureLoader();
|
|
const pmremGen = new THREE.PMREMGenerator(renderer);
|
|
texLoader.load('assets/hdri/environment.jpg', tex => {
|
|
const envRT = pmremGen.fromEquirectangular(tex).texture;
|
|
scene.environment = envRT;
|
|
scene.background = envRT;
|
|
tex.dispose();
|
|
pmremGen.dispose();
|
|
});
|
|
|
|
// --- Schatten-gebendes Licht ---
|
|
const sun = new THREE.DirectionalLight(0xFFA230, 2);
|
|
sun.position.set(21, -25, 30);
|
|
sun.castShadow = true;
|
|
sun.shadow.mapSize.width = 2048;
|
|
sun.shadow.mapSize.height = 2048;
|
|
sun.shadow.camera.near = 1;
|
|
sun.shadow.camera.far = 100;
|
|
sun.shadow.camera.left = -30;
|
|
sun.shadow.camera.right = 30;
|
|
sun.shadow.camera.top = 30;
|
|
sun.shadow.camera.bottom = -30;
|
|
scene.add(sun);
|
|
|
|
// --- GLTF/DRACO Loader ---
|
|
const draco = new DRACOLoader();
|
|
draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
|
|
const gltfLoader = new GLTFLoader();
|
|
gltfLoader.setDRACOLoader(draco);
|
|
|
|
// --- Shadow-Only-Material ---
|
|
const shadowOnlyMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0x000000,
|
|
opacity: 0.01,
|
|
transparent: true,
|
|
depthWrite: false
|
|
});
|
|
|
|
// --- Variablen für Animation ---
|
|
let spinnerRed, spinnerBlue, torigate, landscape, shadowTree;
|
|
const clock = new THREE.Clock();
|
|
let lastSpawn = 0;
|
|
const SPAWN_INT = 5;
|
|
const MOVE_SPEED= 1;
|
|
const spawned = [];
|
|
|
|
// --- Rotierende Lichter um Spinner (nur um Y!) ---
|
|
const rotatingLights = [];
|
|
const counterRotatingLights = [];
|
|
const LIGHT_RADIUS = 1;
|
|
for(let i=0;i<3;i++){
|
|
const L = new THREE.PointLight(0xFFA230, 5, 30);
|
|
L.castShadow = true;
|
|
rotatingLights.push(L);
|
|
scene.add(L);
|
|
|
|
const L2 = new THREE.PointLight(0xFFA230, 5, 30);
|
|
L2.castShadow = true;
|
|
counterRotatingLights.push(L2);
|
|
scene.add(L2);
|
|
}
|
|
|
|
// --- Lazy-Loading/Preload Spirit Mechanik ---
|
|
let spiritModelsList = [];
|
|
let nextSpiritUrl = null;
|
|
let nextSpiritGltf = null;
|
|
let spiritLoadingPromise = null;
|
|
|
|
async function fetchSpiritList(){
|
|
const res = await fetch('assets/models/spirits/');
|
|
const html = await res.text();
|
|
const doc = new DOMParser().parseFromString(html,'text/html');
|
|
spiritModelsList = [...doc.querySelectorAll('a')]
|
|
.map(a => a.getAttribute('href'))
|
|
.filter(h=>h.endsWith('.glb'))
|
|
.map(name=>`assets/models/spirits/${name}`);
|
|
}
|
|
|
|
function prepareNextSpirit() {
|
|
nextSpiritUrl = spiritModelsList[Math.floor(Math.random()*spiritModelsList.length)];
|
|
nextSpiritGltf = null;
|
|
spiritLoadingPromise = gltfLoader.loadAsync(nextSpiritUrl).then(gltf => {
|
|
nextSpiritGltf = gltf;
|
|
}).catch(err => {
|
|
console.warn('Spirit konnte nicht geladen werden:', nextSpiritUrl, err);
|
|
prepareNextSpirit();
|
|
});
|
|
}
|
|
|
|
// --- GLBs laden ---
|
|
async function loadGLB(path, pos, rotDeg, {receiveShadow=false, castShadow=false, emissive=null, visible=true, shadowOnly=false} = {}) {
|
|
const { scene: obj } = await gltfLoader.loadAsync(path);
|
|
obj.position.set(pos[0], pos[1], pos[2]);
|
|
obj.rotation.set(
|
|
THREE.MathUtils.degToRad(rotDeg[0]),
|
|
THREE.MathUtils.degToRad(rotDeg[1]),
|
|
THREE.MathUtils.degToRad(rotDeg[2])
|
|
);
|
|
obj.traverse(c => {
|
|
c.visible = visible;
|
|
if (c.isMesh) {
|
|
c.castShadow = castShadow;
|
|
c.receiveShadow = receiveShadow;
|
|
if (shadowOnly) c.material = shadowOnlyMaterial;
|
|
if (emissive && c.material && c.material.isMeshStandardMaterial) {
|
|
c.material.emissive = new THREE.Color(emissive);
|
|
c.material.emissiveIntensity = 1.0;
|
|
}
|
|
}
|
|
});
|
|
scene.add(obj);
|
|
return obj;
|
|
}
|
|
|
|
// --- Spinner laden ---
|
|
async function loadSpinner(path, pos, rotDeg, color, opacity) {
|
|
const { scene: obj } = await gltfLoader.loadAsync(path);
|
|
obj.position.set(...pos);
|
|
obj.rotation.set(
|
|
THREE.MathUtils.degToRad(rotDeg[0]),
|
|
THREE.MathUtils.degToRad(rotDeg[1]),
|
|
THREE.MathUtils.degToRad(rotDeg[2])
|
|
);
|
|
obj.traverse(c => {
|
|
c.visible = true;
|
|
if (c.isMesh && c.material && c.material.isMeshStandardMaterial) {
|
|
c.material.transparent = true;
|
|
c.material.opacity = opacity;
|
|
c.material.emissive = new THREE.Color(color);
|
|
c.material.emissiveIntensity = 3.0;
|
|
c.material.vertexColors = true;
|
|
c.castShadow = true;
|
|
}
|
|
});
|
|
scene.add(obj);
|
|
return obj;
|
|
}
|
|
|
|
// --- Spirits laden ---
|
|
async function getAndPrepareSpiritInstance(position) {
|
|
// Warte ggf. aufs Laden!
|
|
if (!nextSpiritGltf) await spiritLoadingPromise;
|
|
const s = nextSpiritGltf.scene.clone(true); // mit true: tiefer Clone
|
|
s.traverse(mesh => {
|
|
if(mesh.isMesh){
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
mesh.userData.originalMaterial = mesh.material.clone();
|
|
mesh.material = mesh.material.clone();
|
|
mesh.material.color.set(0xffffcc);
|
|
mesh.material.opacity = 0.0;
|
|
mesh.material.emissive?.set(0xffffcc);
|
|
mesh.material.emissiveIntensity = 2.0;
|
|
}
|
|
});
|
|
s.rotation.x = -Math.PI;
|
|
s.position.copy(position).add(new THREE.Vector3(0, 0, -0.6));
|
|
// Nach dem Spawn gleich das nächste Modell vorbereiten:
|
|
prepareNextSpirit();
|
|
return s;
|
|
}
|
|
|
|
// --- Spirit-Klasse nur noch für Animation/State ---
|
|
class Spirit {
|
|
constructor(obj3d) {
|
|
this.clock = new THREE.Clock();
|
|
this.grp = new THREE.Group();
|
|
this.spiritMeshes = [];
|
|
this.isFading = true;
|
|
this.grp.add(obj3d);
|
|
obj3d.traverse(mesh=>{
|
|
if(mesh.isMesh) this.spiritMeshes.push(mesh);
|
|
});
|
|
scene.add(this.grp);
|
|
}
|
|
|
|
update(dt) {
|
|
const t = this.clock.getElapsedTime();
|
|
if(this.spiritMeshes && this.isFading){
|
|
for(const mesh of this.spiritMeshes){
|
|
if(t < 0.5){
|
|
mesh.material.opacity = 1;
|
|
mesh.material.color.lerp(mesh.userData.originalMaterial.color, t/0.5);
|
|
if(mesh.material.emissive)
|
|
mesh.material.emissive.lerp(mesh.userData.originalMaterial.emissive || new THREE.Color(0x000000), t/0.5);
|
|
mesh.material.emissiveIntensity = 2.0 * (1-t/0.5) + (mesh.userData.originalMaterial.emissiveIntensity||1.0)*(t/0.5);
|
|
} else {
|
|
mesh.material.opacity = mesh.userData.originalMaterial.opacity ?? 1.0;
|
|
mesh.material.color.copy(mesh.userData.originalMaterial.color);
|
|
if(mesh.material.emissive)
|
|
mesh.material.emissive.copy(mesh.userData.originalMaterial.emissive || new THREE.Color(0x000000));
|
|
mesh.material.emissiveIntensity = mesh.userData.originalMaterial.emissiveIntensity ?? 1.0;
|
|
this.isFading = false;
|
|
}
|
|
}
|
|
}
|
|
this.grp.position.y -= MOVE_SPEED * dt;
|
|
if (t > 15) {
|
|
scene.remove(this.grp);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// --- Haupt-Flow ---
|
|
(async()=>{
|
|
await fetchSpiritList();
|
|
prepareNextSpirit();
|
|
landscape = await loadGLB(
|
|
'assets/models/landscape.glb',
|
|
[0,0,0], [90,0,0],
|
|
{receiveShadow:true, castShadow:true}
|
|
);
|
|
torigate = await loadGLB(
|
|
'assets/models/tori.glb',
|
|
[0,6.59,0.375], [90,0,0],
|
|
{receiveShadow:true, castShadow:true}
|
|
);
|
|
spinnerRed = await loadSpinner(
|
|
'assets/models/spinner_red.glb', [0,16.55,0.88], [90,0,0], "#ff3333", 0.2
|
|
);
|
|
spinnerBlue = await loadSpinner(
|
|
'assets/models/spinner_blue.glb', [0,16.55,0.88], [90,0,0], "#3380ff", 0.2
|
|
);
|
|
shadowTree = await loadGLB(
|
|
'assets/models/tree_low.glb',
|
|
[0.0,0.0,0.0], [90,0,0],
|
|
{receiveShadow:false, castShadow:true, shadowOnly: true}
|
|
);
|
|
|
|
function spawnSpirit(){
|
|
// Spawn exakt EIN Spirit, Modell ist garantiert geladen:
|
|
getAndPrepareSpiritInstance(spinnerRed.position.clone().add(new THREE.Vector3(0,-1.5,0))).then(obj=>{
|
|
spawned.push(new Spirit(obj));
|
|
});
|
|
}
|
|
|
|
function animate(){
|
|
const dt = clock.getDelta(), t = clock.getElapsedTime();
|
|
|
|
// Spinner Animation
|
|
const bob = Math.sin(t*1.2)*0.5;
|
|
const baseY = 16.55 + bob;
|
|
spinnerRed.position.y = baseY + 0.8;
|
|
spinnerBlue.position.y = baseY;
|
|
spinnerRed.rotation.y -= 1.2 * dt;
|
|
spinnerBlue.rotation.y += 1.2 * dt;
|
|
|
|
// Rotierende Lichter
|
|
const center = new THREE.Vector3(0, 16.55, 1.5);
|
|
const lightZ = center.z;
|
|
for(let i=0; i<rotatingLights.length; i++){
|
|
const ang = t * 0.8 + i * 2 * Math.PI / 3;
|
|
rotatingLights[i].position.set(
|
|
center.x + Math.cos(ang) * LIGHT_RADIUS,
|
|
center.y + Math.sin(ang) * LIGHT_RADIUS,
|
|
lightZ
|
|
);
|
|
}
|
|
for(let i=0; i<counterRotatingLights.length; i++){
|
|
const ang = -t * 0.8 + i * 2 * Math.PI / 3;
|
|
counterRotatingLights[i].position.set(
|
|
center.x + Math.cos(ang) * LIGHT_RADIUS,
|
|
center.y + Math.sin(ang) * LIGHT_RADIUS,
|
|
lightZ
|
|
);
|
|
}
|
|
|
|
// Spawn Spirits
|
|
if (t - lastSpawn > SPAWN_INT) {
|
|
lastSpawn = t;
|
|
spawnSpirit();
|
|
}
|
|
|
|
// update & cleanup
|
|
for (let i = spawned.length-1; i >= 0; i--) {
|
|
if (!spawned[i].update(dt)) spawned.splice(i,1);
|
|
}
|
|
|
|
composer.render(scene, camera);
|
|
requestAnimationFrame(animate);
|
|
}
|
|
animate();
|
|
})();
|
|
|
|
</script>
|
|
</body>
|
|
</html> |