163 lines
7.0 KiB
JavaScript
163 lines
7.0 KiB
JavaScript
/*!
|
|
* 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.0; // breedte van één grastegel (hoogte volgt de beeldverhouding)
|
|
// assets/grass.jpg is kleurgecorrigeerd naar het gemiddelde grasgroen van het
|
|
// panorama recht onder de camera (bron: "grass (kopie).jpg"). Het panorama
|
|
// is onbelicht; het gras wordt belicht door ambient (0.6) + zon (0.8, vanuit
|
|
// SUN_OFFSET). Deze factor maakt belicht gras gelijk aan de textuurkleur.
|
|
const SUN_OFFSET = { x: 8, y: 14, z: 6 };
|
|
const GRASS_SHADE = 1 / (0.6 + 0.8 * SUN_OFFSET.y / Math.hypot(SUN_OFFSET.x, SUN_OFFSET.y, SUN_OFFSET.z));
|
|
|
|
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;
|
|
const aspect = (map.image && map.image.width) ? map.image.height / map.image.width : 1;
|
|
tex.repeat.set(2 * R / GRASS_TILE_M, 2 * R / (GRASS_TILE_M * aspect));
|
|
const material = new THREE.MeshLambertMaterial({
|
|
map: tex, color: new THREE.Color(GRASS_SHADE, GRASS_SHADE, 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 <begin_vertex>', '#include <begin_vertex>\nvGrassR = length(transformed.xy);');
|
|
shader.fragmentShader = 'varying float vGrassR;\n' + shader.fragmentShader.replace(
|
|
'#include <dithering_fragment>',
|
|
`#include <dithering_fragment>\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 + SUN_OFFSET.x, o.env.group.position.y + SUN_OFFSET.y, o.env.group.position.z + SUN_OFFSET.z);
|
|
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 = '<i class="bi bi-cloud-sun"></i>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 };
|
|
})();
|