v0.4.3: lucht op de grond (grounded skybox) met gras en schaduw; webserver verplicht

- core/skybox.js: panorama op de grond geprojecteerd (port GroundedSkybox),
  grastextuur in cirkel rond het object met overvloeiing, zon met schaduw,
  camera niet onder de grond
- woning: maaiveld als instelling (standaard 350 mm onder bovenkant vloer)
- assets opgeschoond (alleen grass.jpg + skybox.webp), skybox-data.js weg
- start.sh + melding bij file://

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 12:07:35 +02:00
co-authored by Claude Opus 5.5
parent 3a4b9608c0
commit f8da490640
12 changed files with 175 additions and 80 deletions
+114 -39
View File
@@ -5,41 +5,101 @@
* Zie LICENTIE.md.
*/
// ============================================================
// core/skybox.js — lucht-achtergrond (equirectangulair panorama) voor de
// 3D-weergaven, met een "Lucht"-knop in de 3D-werkbalk. De afbeelding
// (assets/skybox-data.js, gegenereerd uit assets/skybox.webp) wordt pas bij
// de eerste keer aanzetten geladen en daarna gedeeld. De keuze aan/uit wordt
// onthouden (localStorage, alleen gemak).
// Plain script (geen ES-module).
// 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';
let texturePromise = null;
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)
function load() {
if (texturePromise) return texturePromise;
texturePromise = new Promise((resolve, reject) => {
const fromData = () => {
const img = new Image();
img.onload = () => {
const tex = new THREE.Texture(img);
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.needsUpdate = true;
resolve(tex);
};
img.onerror = reject;
img.src = window.TECTUM_SKYBOX;
};
if (window.TECTUM_SKYBOX) return fromData();
const s = document.createElement('script');
s.src = 'assets/skybox-data.js';
s.onload = fromData;
s.onerror = reject;
document.head.appendChild(s);
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 };
});
texturePromise.catch(() => { texturePromise = null; });
return texturePromise;
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 <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() {
@@ -49,21 +109,36 @@ window.Skybox = (() => {
try { localStorage.setItem(PREF_KEY, on ? '1' : '0'); } catch (e) { /* geen opslag: alleen deze sessie */ }
}
// Zet de achtergrond van `scene`: lucht als on, anders fallbackColor (hex).
// wanted() wordt na het laden nog eens gevraagd (knop intussen uitgezet?).
function apply(scene, on, fallbackColor, wanted) {
if (!on) { scene.background = new THREE.Color(fallbackColor); return; }
load().then(tex => { if (!wanted || wanted()) scene.background = tex; })
.catch(() => { scene.background = new THREE.Color(fallbackColor); });
// 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 (en niet bij
// aanmaken: de aanroeper leest de begintoestand met isOn()).
// "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 als achtergrond';
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');
@@ -75,5 +150,5 @@ window.Skybox = (() => {
return btn;
}
return { load, isOn, apply, createToggle };
return { CAMERA_HEIGHT_M, createEnvironment, apply, isOn, createToggle };
})();