Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s

Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
This commit is contained in:
Emil
2026-09-17 02:10:53 +03:00
parent 26748912c6
commit c7e86663d8
111 changed files with 24257 additions and 598 deletions
+417 -61
View File
@@ -1,64 +1,420 @@
<!doctype html><meta charset="utf-8" /><title>Shacraft renderer fixture</title
><style>
body {
margin: 0;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}</style
><canvas id="c"></canvas>
<script type="module">
import { Renderer } from "../renderer.js";
const canvas = document.querySelector("canvas");
try {
const r = new Renderer(canvas),
blocks = new Map(),
mats = new Map([
[
1,
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Shacraft daylight renderer fixture</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font:
13px/1.4 system-ui,
sans-serif;
color: #172c23;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}
.controls {
position: fixed;
top: 14px;
left: 14px;
right: 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
width: fit-content;
max-width: calc(100vw - 28px);
padding: 10px 14px;
border: 1px solid #ffffffb3;
border-radius: 7px;
background: #fffffff0;
box-shadow: 0 2px 10px #153c2515;
}
label {
display: flex;
align-items: center;
gap: 6px;
}
select,
button {
font: inherit;
color: inherit;
padding: 5px 9px;
background: #f6f8f3;
border: 1px solid #aebdaf;
border-radius: 4px;
}
button {
cursor: pointer;
}
input {
accent-color: #436953;
}
output {
color: #47604c;
font-variant-numeric: tabular-nums;
}
.legend {
position: fixed;
left: 14px;
bottom: 14px;
margin: 0;
padding: 8px 12px;
background: #ffffffe6;
border-radius: 5px;
max-width: calc(100vw - 28px);
}
#error {
position: fixed;
inset: 80px 14px auto;
white-space: pre-wrap;
background: #fff0ec;
color: #7f2217;
padding: 16px;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<canvas id="c" aria-label="Daylight renderer test scene"></canvas>
<div class="controls" aria-label="Renderer test controls">
<strong>Daylight fixture</strong>
<label
>Camera
<select id="camera">
<option value="overview">Overview</option>
<option value="corner">Corner</option>
<option value="sun-facing">Sun facing</option>
<option value="lamp-room">Lamp room</option>
<option value="actor-boundary-before">Actors · before section boundary</option>
<option value="actor-boundary-after">Actors · after section boundary</option>
</select>
</label>
<label><input id="shadows" type="checkbox" checked /> Shadows</label>
<button id="pillar" type="button">Remove pillar</button>
<label
>Room lamp
<select id="lamp">
<option value="torch">Torch · 14</option>
<option value="sea-lantern">Sea lantern · 15</option>
<option value="off">Off · 0</option>
</select>
</label>
<button id="wall" type="button">Remove room partition</button>
<output id="status" role="status">Building scene…</output>
</div>
<p class="legend">
Stone corner · arch and posts · slabs and stairs · glass and water · sea
lantern · avatar and entity · sealed lamp room with an internal glass
window
<br /><output id="light-samples">Light field initializing…</output>
</p>
<pre id="error" hidden></pre>
<script type="module">
import { Renderer } from "../renderer.js?v=block-light-1";
const canvas = document.querySelector("canvas"),
status = document.querySelector("#status"),
cameraSelect = document.querySelector("#camera"),
shadowToggle = document.querySelector("#shadows"),
pillarButton = document.querySelector("#pillar"),
lampSelect = document.querySelector("#lamp"),
wallButton = document.querySelector("#wall"),
lightStatus = document.querySelector("#light-samples");
const cube = { min: [0, 0, 0], max: [1, 1, 1] };
const lookAt = (eye, target) => {
const direction = target.map((value, axis) => value - eye[axis]);
return {
eye,
yaw: Math.atan2(direction[0], -direction[2]),
pitch: Math.atan2(
direction[1],
Math.hypot(direction[0], direction[2]),
),
};
};
const cameras = {
overview: lookAt([12, 10, 15], [0, 1.8, -3]),
corner: lookAt([-2.4, 3.6, -2.1], [-6.2, 1.8, -8.6]),
"sun-facing": {
eye: [7, 1.9, -9],
yaw: Math.atan2(-0.45, -0.3),
pitch: 0.65,
},
"lamp-room": lookAt([-15.5, 2.65, 12.5], [-13.5, 2.1, 7]),
"actor-boundary-before": lookAt([15.99, 4, 8], [3.5, 2, 1.4]),
"actor-boundary-after": lookAt([16.01, 4, 8], [3.5, 2, 1.4]),
};
function fail(error) {
document.body.dataset.status = "error:" + error.message;
status.textContent = "Renderer error";
const output = document.querySelector("#error");
output.hidden = false;
output.textContent = error.stack || String(error);
}
try {
const renderer = new Renderer(canvas),
blocks = new Map(),
materials = new Map(),
material = (id, state, color, options = {}) =>
materials.set(id, {
state: `minecraft:${state}`,
color,
opacity: 1,
render: [cube],
light: 0,
...options,
}),
put = (x, y, z, block) => blocks.set(`${x},${y},${z}`, block);
material(1, "grass_block", [112, 155, 69]);
material(2, "stone", [177, 176, 163]);
material(3, "stone_bricks", [186, 181, 164]);
material(4, "oak_log[axis=y]", [139, 108, 69]);
material(5, "oak_planks", [188, 152, 100]);
material(6, "glass", [183, 221, 221], { opacity: 0.28 });
material(7, "water[level=0]", [74, 149, 184], {
opacity: 0.58,
render: [{ min: [0, 0, 0], max: [1, 0.88, 1] }],
});
material(8, "stone_slab[type=bottom]", [178, 176, 159], {
render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }],
});
material(
9,
"stone_stairs[facing=north,half=bottom,shape=straight]",
[179, 177, 160],
{
state: "minecraft:grass_block",
color: [107, 151, 65],
opacity: 1,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
render: [
{ min: [0, 0, 0], max: [1, 0.5, 1] },
{ min: [0, 0.5, 0], max: [1, 1, 0.5] },
],
},
],
[
2,
{
state: "minecraft:bricks",
color: [169, 88, 68],
opacity: 1,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
[
3,
{
state: "minecraft:glass",
color: [169, 220, 219],
opacity: 0.35,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
]);
for (let x = -16; x <= 16; x++)
for (let z = -16; z <= 16; z++) blocks.set(`${x},0,${z}`, 1);
for (let x = -3; x < 3; x++)
for (let y = 1; y < 5; y++)
blocks.set(
`${x},${y},-7`,
(x === 0 || x === 1) && y > 1 && y < 4 ? 3 : 2,
);
r.replace(blocks, mats);
for (let n = 0; n < 24; n++) {
r.draw({ eye: [7, 5, 12], yaw: -0.38, pitch: -0.2 }, [], [], new Map());
}
document.body.dataset.status = `ready:${r.triangles}`;
} catch (error) {
document.body.dataset.status = "error:" + error.message;
document.body.textContent = error.stack;
}
</script>
material(10, "sea_lantern", [221, 244, 224], { light: 15 });
material(11, "torch", [229, 158, 70], {
light: 14,
render: [{ min: [0.43, 0, 0.43], max: [0.57, 0.8, 0.57] }],
});
material(12, "quartz_block", [211, 207, 189]);
for (let x = -18; x <= 18; x++)
for (let z = -20; z <= 16; z++)
put(
x,
0,
z,
Math.abs(x) <= 2 || (x >= -7 && x <= -3 && z >= -10 && z <= -3)
? 2
: 1,
);
// A tall inside corner and a small roof expose contact and indirect light.
for (let y = 1; y <= 5; y++) {
for (let x = -7; x <= -3; x++) put(x, y, -10, 3);
for (let z = -9; z <= -4; z++) put(-7, y, z, 3);
}
for (let x = -7; x <= -5; x++)
for (let z = -10; z <= -8; z++) put(x, 5, z, 3);
put(-6, 1, -9, 10);
// The right support can be removed independently of the lintel.
const removablePillar = [];
for (let y = 1; y <= 4; y++) {
put(0, y, -6, 4);
put(5, y, -6, 4);
removablePillar.push([5, y, -6]);
}
for (let x = 0; x <= 5; x++) put(x, 5, -6, 5);
for (let y = 1; y <= 3; y++) put(7, y, 1, 4);
for (let y = 1; y <= 5; y++) put(9, y, -3, 3);
for (let x = -5; x <= -2; x++) put(x, 1, 3, 8);
for (let x = -5; x <= -2; x++) put(x, 1, 1, 9);
put(-5, 2, 1, 8);
for (let x = 3; x <= 5; x++)
for (let y = 1; y <= 3; y++) put(x, y, 4, 6);
for (let x = 6; x <= 10; x++)
for (let z = -11; z <= -7; z++) {
put(x, -1, z, 2);
put(x, 0, z, 7);
}
for (let x = 5; x <= 11; x++) {
put(x, 0, -12, 3);
put(x, 0, -6, 3);
}
for (let z = -11; z <= -7; z++) {
put(5, 0, z, 3);
put(11, 0, z, 3);
}
// All exterior faces are solid, including the roof. The window connects
// two indoor chambers so lamp-off darkness is independent of daylight.
for (let x = -17; x <= -9; x++)
for (let z = 4; z <= 14; z++) {
put(x, 0, z, 2);
put(x, 5, z, 12);
if (x === -17 || x === -9 || z === 4 || z === 14)
for (let y = 1; y <= 4; y++) put(x, y, z, 12);
}
for (let z = 5; z <= 13; z++)
for (let y = 1; y <= 4; y++) put(-13, y, z, 12);
put(-13, 2, 5, 6);
put(-13, 3, 5, 6);
const lampPosition = [-15, 2, 8],
partitionPanel = [];
put(-15, 1, 8, 2);
put(...lampPosition, 11);
for (let y = 1; y <= 3; y++)
for (let z = 8; z <= 9; z++) partitionPanel.push([-13, y, z]);
const players = [
{
id: "fixture-player",
name: "Avatar",
position: [3.5, 1, 1.4],
yaw: -0.5,
pose: "standing",
height: 1.8,
eye_height: 1.62,
color: [0.31, 0.47, 0.64],
},
];
const entities = [
{ kind: "fixture-crate", position: [-3, 1, -4], yaw: 0.3 },
];
const entityDefinitions = new Map([
[
"fixture-crate",
{
color: [168, 121, 81],
render: [{ min: [-0.5, 0, -0.5], max: [0.5, 1, 0.5] }],
},
],
]);
renderer.replace(blocks, materials);
renderer.shadowsEnabled = shadowToggle.checked;
let pillarPresent = true,
wallPresent = true,
frameCount = 0,
lastStatus = -Infinity;
const errors = new Set();
document.body.dataset.camera = cameraSelect.value;
document.body.dataset.pillar = "present";
document.body.dataset.lamp = lampSelect.value;
document.body.dataset.wall = "present";
shadowToggle.onchange = () => {
renderer.shadowsEnabled = shadowToggle.checked;
};
cameraSelect.onchange = () => {
document.body.dataset.camera = cameraSelect.value;
};
pillarButton.onclick = () => {
pillarPresent = !pillarPresent;
const changes = removablePillar.map((pos) => {
if (pillarPresent) blocks.set(pos.join(","), 4);
else blocks.delete(pos.join(","));
return { pos, block: pillarPresent ? 4 : 0 };
});
renderer.change(changes);
pillarButton.textContent = pillarPresent
? "Remove pillar"
: "Restore pillar";
document.body.dataset.pillar = pillarPresent ? "present" : "removed";
};
lampSelect.onchange = () => {
const block = { torch: 11, "sea-lantern": 10, off: 0 }[
lampSelect.value
];
if (block) blocks.set(lampPosition.join(","), block);
else blocks.delete(lampPosition.join(","));
renderer.change([{ pos: lampPosition, block }]);
document.body.dataset.lamp = lampSelect.value;
};
wallButton.onclick = () => {
wallPresent = !wallPresent;
const changes = partitionPanel.map((pos) => {
if (wallPresent) blocks.set(pos.join(","), 12);
else blocks.delete(pos.join(","));
return { pos, block: wallPresent ? 12 : 0 };
});
renderer.change(changes);
wallButton.textContent = wallPresent
? "Remove room partition"
: "Restore room partition";
document.body.dataset.wall = wallPresent ? "present" : "removed";
};
const lightSamplePositions = {
source: lampPosition,
near: [-15, 2, 9],
window: [-13, 2, 5],
behindWall: [-12, 2, 8],
far: [-10, 2, 12],
outside: [-15, 2, 15],
};
function updateLightSamples() {
const field = renderer.blockLightField;
if (typeof field?.sample !== "function") return;
const samples = Object.fromEntries(
Object.entries(lightSamplePositions).map(([name, position]) => {
const sample = field.sample(position);
return [name, { block: sample.blockLevel, sky: sample.skyLevel }];
}),
);
document.body.dataset.lightSamples = JSON.stringify(samples);
document.body.dataset.lightSources = String(field.sourceCount);
lightStatus.textContent = `Light 0–15 (block/sky): ${Object.entries(
samples,
)
.map(([name, sample]) => `${name} ${sample.block}/${sample.sky}`)
.join(" · ")}`;
}
function frame(time) {
try {
renderer.draw(
cameras[cameraSelect.value],
players,
entities,
entityDefinitions,
);
frameCount++;
const error = renderer.gl.getError();
if (error !== renderer.gl.NO_ERROR)
errors.add(`0x${error.toString(16)}`);
if (time - lastStatus >= 200) {
lastStatus = time;
const ready = frameCount >= 3 && renderer.dirty.size === 0;
document.body.dataset.status = errors.size
? `error:WebGL ${[...errors].join(",")}`
: `${ready ? "ready" : "building"}:${renderer.triangles}`;
document.body.dataset.glError = errors.size
? [...errors].join(",")
: "0";
document.body.dataset.triangles = String(renderer.triangles);
document.body.dataset.sections = String(renderer.sections.size);
document.body.dataset.pendingSections = String(
renderer.dirty.size,
);
document.body.dataset.shadowsEnabled = String(
renderer.shadowsEnabled,
);
document.body.dataset.frames = String(frameCount);
document.body.dataset.meshRebuilds = String(
renderer.meshRebuilds,
);
updateLightSamples();
status.textContent = `${errors.size ? "WebGL " + [...errors].join(",") : "WebGL OK"} · ${renderer.triangles.toLocaleString("en-US")} triangles · shadows ${renderer.shadowsEnabled ? "on" : "off"}`;
}
requestAnimationFrame(frame);
} catch (error) {
fail(error);
}
}
requestAnimationFrame(frame);
} catch (error) {
fail(error);
}
</script>
</body>
</html>