/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // core/skybox.js — omgeving voor de 3D-weergaven ("Lucht"-knop): een // panorama (assets/skybox.webp) dat op de grond geprojecteerd wordt, zodat // het object echt op het gras staat, plus scherp gras (assets/grass.jpg) in // een cirkel rond het object dat naar buiten toe overvloeit in het gras van // het panorama. Het gras vangt ook de schaduw op. // // Grondprojectie: port van three.js GroundedSkybox (r157+, MIT; wij draaien // r128): een binnenstebuiten bol waarvan de onderhelft plat gedrukt wordt op // een vloer op CAMERA_HEIGHT_M onder het midden van de bol. // // Vereist een webserver (texturen via TextureLoader). Plain script. // ============================================================ window.Skybox = (() => { const PREF_KEY = 'tectum.skybox'; const SKY_URL = 'assets/skybox.webp'; const GRASS_URL = 'assets/grass.jpg'; const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven het gras const RADIUS_M = 60; // straal van de omgevingsbol const GRASS_RADIUS_M = 12; // tot hier is het eigen gras volledig zichtbaar const GRASS_FADE_M = 6; // daarna vloeit het over in het panorama const GRASS_TILE_M = 2.5; // één grastegel beslaat zoveel meter const GRASS_SHADE = 0x9a9a9a; // compenseert ambient + zon (anders te licht) let loading = null; function loadTextures() { if (loading) return loading; const loader = new THREE.TextureLoader(); const load = (url) => new Promise((resolve, reject) => loader.load(url, resolve, undefined, reject)); loading = Promise.all([load(SKY_URL), load(GRASS_URL)]).then(([sky, grass]) => { grass.wrapS = grass.wrapT = THREE.RepeatWrapping; grass.anisotropy = 8; return { sky, grass }; }); loading.catch(() => { loading = null; }); return loading; } function buildSky(map) { const geometry = new THREE.SphereGeometry(RADIUS_M, 256, 128); geometry.scale(1, 1, -1); // binnenkant zichtbaar const pos = geometry.getAttribute('position'); const v = new THREE.Vector3(); const h = CAMERA_HEIGHT_M, y1 = -h * 3 / 2; for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i); if (v.y < 0) { // Onderhelft plat op y = -h; vloeiende overgang naar de bol. const f = v.y < y1 ? -h / v.y : (1 - v.y * v.y / (3 * y1 * y1)); v.multiplyScalar(f); pos.setXYZ(i, v.x, v.y, v.z); } } pos.needsUpdate = true; const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false })); mesh.position.y = h - 0.01; // vlakke vloer net onder het eigen gras mesh.renderOrder = -1; return mesh; } function buildGrass(map) { const R = GRASS_RADIUS_M + GRASS_FADE_M; const tex = map.clone(); tex.needsUpdate = true; tex.repeat.set(2 * R / GRASS_TILE_M, 2 * R / GRASS_TILE_M); const material = new THREE.MeshLambertMaterial({ map: tex, color: GRASS_SHADE, transparent: true, depthWrite: false }); // Randvervaging op afstand tot het midden (eigen varying: de UV's zijn // al herhaald voor de textuur). material.onBeforeCompile = (shader) => { shader.vertexShader = 'varying float vGrassR;\n' + shader.vertexShader.replace( '#include ', '#include \nvGrassR = length(transformed.xy);'); shader.fragmentShader = 'varying float vGrassR;\n' + shader.fragmentShader.replace( '#include ', `#include \ngl_FragColor.a *= 1.0 - smoothstep(${GRASS_RADIUS_M.toFixed(1)}, ${R.toFixed(1)}, vGrassR);`); }; const mesh = new THREE.Mesh(new THREE.CircleGeometry(R, 96), material); mesh.rotation.x = -Math.PI / 2; mesh.receiveShadow = true; return mesh; } // Omgeving voor één scene. Geeft { group, setPlacement(x, groundY, z) }: // het midden (m) waar het object staat en de hoogte van het maaiveld (m). function createEnvironment(scene) { const group = new THREE.Group(); group.visible = false; scene.add(group); loadTextures().then(({ sky, grass }) => { group.add(buildSky(sky)); group.add(buildGrass(grass)); }).catch(() => console.warn('Tectum: lucht/gras niet geladen (draait de app via een webserver?)')); return { group, setPlacement(x, groundY, z) { group.position.set(x, groundY, z); } }; } function isOn() { try { return localStorage.getItem(PREF_KEY) === '1'; } catch (e) { return false; } } function remember(on) { try { localStorage.setItem(PREF_KEY, on ? '1' : '0'); } catch (e) { /* geen opslag: alleen deze sessie */ } } // Lucht aan/uit voor een weergave: omgeving tonen, raster verbergen, camera // niet onder de grond, zon met schaduw op het gras. // o: { env, grid, controls, sun, shadowRoot (Object3D die schaduw werpt) } function apply(on, o) { o.env.group.visible = on; if (o.grid) o.grid.visible = !on; if (o.controls) o.controls.maxPolarAngle = on ? Math.PI / 2 - 0.03 : Math.PI - 0.001; if (o.sun) { o.sun.castShadow = on; if (on) { const cam = o.sun.shadow.camera; cam.left = cam.bottom = -GRASS_RADIUS_M; cam.right = cam.top = GRASS_RADIUS_M; cam.near = 0.5; cam.far = 60; cam.updateProjectionMatrix(); o.sun.shadow.mapSize.set(2048, 2048); o.sun.shadow.bias = -0.0005; o.sun.position.set(o.env.group.position.x + 8, 14, o.env.group.position.z + 6); o.sun.target.position.copy(o.env.group.position); o.sun.target.updateMatrixWorld(); } } if (o.shadowRoot) o.shadowRoot.traverse(m => { if (m.isMesh) m.castShadow = on; }); } // "Lucht"-knop in een 3D-werkbalk. onChange(on) bij elke klik. function createToggle(toolbar, onChange) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn-3d-toggle' + (isOn() ? ' active' : ''); btn.title = 'Lucht en gras als omgeving'; btn.innerHTML = 'Lucht'; btn.addEventListener('click', () => { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); remember(on); onChange(on); }); toolbar.appendChild(btn); return btn; } return { CAMERA_HEIGHT_M, createEnvironment, apply, isOn, createToggle }; })();