Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ These paths are relative to the development server root:
| `demo/custom_model/` | Java model JSON editor |
| `demo/exports/` | Image, video, and 3D export of a small instanced scene |
| `demo/materials/` | Canvas material shader regression check |
| `demo/transparency/` | Compares translucent face ordering with labeled panels, a shared camera, and separate-section reference |

The sidebar from `src/playground/Playground.ts` provides presets, renderer and camera settings, Minecraft version / hosted root / resource-pack ZIP, image, video, and 3D exports, and a shareable link or JSON that restores the page state. Local files are not part of shared links and have to be selected again.

Expand Down
4 changes: 4 additions & 0 deletions apps/web/scripts/check-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ async function visit(url, name) {
if (!await page.$("#viewport canvas")) {
problems.add("Editor canvas missing");
}
} else if (await page.$("#camera-angle")) {
const result = await page.evaluate(() => window.transparencyTest);
status = await page.$eval("#summary", element => element.textContent ?? "");
if (result?.error) problems.add(result.error);
} else {
await new Promise(resolve => setTimeout(resolve, 3000));
status = (await page.evaluate(() => document.body.innerText)).split("\n").find(line => /PASS|FAIL/.test(line)) ?? "(no status)";
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/demo/transparency/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { AssetLoader, AssetParser, AssetSource, Caching, MineRenderScene, MineRenderWorld,
type AssetKey, type MinecraftAsset, type MineRenderDataManifest, type Model, type TextureAsset } from "minerender";

const NAMESPACE = "transparency";
type SlabColor = "red" | "blue";

function texture(color: SlabColor): TextureAsset {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 64;
const context = canvas.getContext("2d")!;
const pixels = context.createImageData(64, 64);
for (let y = 0; y < 64; y++) {
for (let x = 0; x < 64; x++) {
const grid = x % 16 === 0 || y % 16 === 0;
const low = grid ? 40 : 0;
pixels.data.set(color === "red" ? [255, low, low, 128] : [low, low, 255, 128], (y * 64 + x) * 4);
}
}
context.putImageData(pixels, 0, 0);
context.strokeStyle = color === "red" ? "#ffc4c4" : "#c4d4ff";
context.lineWidth = 2;
context.strokeRect(1, 1, 62, 62);
context.fillStyle = "#ffffff";
context.font = "bold 20px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(color === "red" ? "A" : "B", 32, 33);
const encoded = atob(canvas.toDataURL("image/png").split(",")[1]);
return { width: 64, height: 64, type: "png", data: Uint8Array.from(encoded, character => character.charCodeAt(0)) };
}

function model(color: SlabColor): Model {
return {
textures: { surface: `${NAMESPACE}:block/${color}` },
elements: [{ from: [color === "red" ? -6 : 2, -2, 7.9], to: [color === "red" ? 14 : 22, 18, 8.1], shade: false,
faces: { north: { texture: "#surface", uv: [0, 0, 16, 16] }, south: { texture: "#surface", uv: [0, 0, 16, 16] } } }]
};
}

class FixtureSource extends AssetSource {
private readonly textures = { red: texture("red"), blue: texture("blue") };

constructor() { super(); }

public blocks(): boolean { return true; }

public async get<T extends MinecraftAsset>(key: AssetKey, parser: AssetParser | string): Promise<T | undefined> {
let asset: unknown;
if (key.namespace === "minerender-data") {
if (key.path === "manifest") {
const dataset = (file: string) => ({ file: `${file}.json`, sha256: "0".repeat(64), bytes: 2, provenance: {} });
const manifest: MineRenderDataManifest = {
schemaVersion: 1, minecraftVersion: AssetLoader.version, dataVersion: 0,
datasets: { blockStates: dataset("block-states"), fluids: dataset("fluids"), blockTints: dataset("block-tints") },
unavailable: {}, extractor: { repository: "fixture", revision: "fixture", sha256: "0".repeat(64) }
};
asset = manifest;
} else if (["block-states", "fluids", "block-tints"].includes(key.path)) asset = {};
} else if (key.namespace === "entity-models" && key.path === "blocks") asset = {};
else if (key.namespace === NAMESPACE && (key.path === "red" || key.path === "blue")) {
if (parser === AssetParser.BLOCKSTATE) asset = { variants: { "": { model: `${NAMESPACE}:block/${key.path}` } } };
else if (parser === AssetParser.MODEL) asset = model(key.path);
else if (parser === AssetParser.IMAGE) asset = { ...this.textures[key.path] };
}
return asset as T | undefined;
}
}

let installed = false;

/** Installs local fixture assets and prevents fallback requests to hosted sources. */
export function installFixtureAssets(): void {
if (installed) return;
AssetLoader.addSource("transparency-fixture", new FixtureSource());
Caching.clear();
installed = true;
}

/** Places the same two translucent slabs within one section or across its boundary. */
export async function createFixture(splitSections: boolean, reversePlacement: boolean): Promise<{
scene: MineRenderScene; world: MineRenderWorld<true>; targetZ: number;
}> {
installFixtureAssets();
const scene = new MineRenderScene();
const world = new MineRenderWorld(scene, { sectionMeshing: true });
const redZ = splitSections ? 15 : 0;
const placements: { color: SlabColor; z: number }[] = [{ color: "red", z: redZ }, { color: "blue", z: redZ + 1 }];
if (reversePlacement) placements.reverse();
for (const { color, z } of placements) await world.setBlockAt(0, 0, z, { type: `${NAMESPACE}:${color}` });
return { scene, world, targetZ: splitSections ? 248 : 8 };
}
75 changes: 75 additions & 0 deletions apps/web/src/demo/transparency/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>World transparency comparison | MineRender</title>
<link rel="stylesheet" href="../../demo_shared.css">
<style>
html { height: auto; min-height: 100%; overflow: auto; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; }
button { font: inherit; color: inherit; background: #222b35; border: 1px solid #586575; border-radius: 4px; padding: 8px 12px; cursor: pointer; }
button:hover { background: #334339; border-color: #9bdca4; }
button:disabled, input:disabled { opacity: .55; cursor: default; }
:focus-visible { outline: 2px solid #a5e8b0; outline-offset: 3px; }
.camera-control { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
input[type=range] { width: min(100%, 320px); accent-color: #9bdca4; }
#status { min-height: 26px; font-weight: 650; color: #ecf1f5; }
.pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin: 24px 0; }
figure { margin: 0; min-width: 0; }
canvas { display: block; width: 100%; height: auto; background: #151b23; }
figcaption { margin-bottom: 8px; font-weight: 650; }
.note { display: block; font-size: 14px; font-weight: 400; color: #bdc8d2; }
#order, output { color: #bdc8d2; }
details { margin-top: 28px; }
summary { cursor: pointer; }
@media (max-width: 600px) {
.playground-index main { padding: 28px 16px; }
.pair { grid-template-columns: minmax(0, 1fr); }
}
</style>
</head>
<body class="playground-index">
<main>
<a href="../../">All playgrounds</a>
<h1>Compare translucent panels across world sections</h1>
<p>Panel <strong>A is red</strong>; panel <strong>B is blue</strong>. Both are translucent fixture blocks. You should see the rear panel’s letter and grid through the translucent parts of the front panel.</p>
<p>Compare the panels merged into one world section with the same panels split across two sections, then move the shared camera to see their spacing.</p>
<div class="camera-control">
<label for="camera-angle">Camera angle</label>
<input id="camera-angle" type="range" min="0" max="180" value="180" step="1" disabled>
<output id="angle" for="camera-angle">180° · A in front</output>
</div>
<div class="controls" aria-label="Camera presets">
<button type="button" data-angle="180" disabled>A in front</button>
<button type="button" data-angle="65" disabled>See panel spacing</button>
<button type="button" data-angle="0" disabled>B in front</button>
</div>
<p id="status" role="status" aria-live="polite">Loading the two panels…</p>
<p id="summary" role="status"></p>
<div class="pair">
<figure>
<figcaption>Merged section — actual<span class="note">Both panels share one mesh.</span></figcaption>
<div id="merged"></div>
</figure>
<figure>
<figcaption>Separate sections — comparison<span class="note">Both panels contribute to the overlap in this fixture.</span></figcaption>
<div id="separate"></div>
</figure>
</div>
<p id="explanation">Start with A in front. Look for the blue B and its grid inside the red panel.</p>
<div class="controls">
<button id="reverse" type="button" disabled>Reverse placement order</button>
<span id="order">Placement: A, then B</span>
</div>
<p>Reversing placement keeps both panels in place. It changes their draw order.</p>
<details>
<summary>Reproduction details</summary>
<p>This uses real <code>MineRenderWorld</code> placement with <code>sectionMeshing: true</code>. The comparison shifts the panels and camera together by 15 blocks, putting the panels on opposite sides of a section boundary. Geometry, textures, spacing, and materials stay the same.</p>
<p>Generated textures have alpha 128/255 with opaque letters and borders. All assets stay local. The floor grid provides depth context. This comparison demonstrates this two-panel case; it does not establish general correctness across section boundaries.</p>
<p id="measurements"></p>
</details>
</main>
<script type="module" src="script.bundle.js"></script>
</body>
</html>
152 changes: 152 additions & 0 deletions apps/web/src/demo/transparency/script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { AssetLoader, Caching, shutdown } from "minerender";
import * as THREE from "three";
import { createFixture, installFixtureAssets } from "./fixture";

type Fixture = Awaited<ReturnType<typeof createFixture>>;
type View = Fixture & { grid: THREE.GridHelper };
const width = 480, height = 360;
const RGB_TOLERANCE = 8;
const MIN_DIFFERING_PIXELS = 100;
const button = document.getElementById("reverse") as HTMLButtonElement;
const slider = document.getElementById("camera-angle") as HTMLInputElement;
const controls = Array.from(document.querySelectorAll<HTMLButtonElement | HTMLInputElement>("button, input"));
const status = document.getElementById("status")!;
const summary = document.getElementById("summary")!;
const order = document.getElementById("order")!;
const angleOutput = document.getElementById("angle")!;
const explanation = document.getElementById("explanation")!;
const measurements = document.getElementById("measurements")!;
let reversed = false;
let views: View[] = [];
const renderers: THREE.WebGLRenderer[] = [];

function meshCount(scene: THREE.Scene): number {
let count = 0;
scene.traverse(object => { if (object instanceof THREE.Mesh) count++; });
return count;
}

function draw() {
if (views.length !== 2) return;
const angle = Number(slider.value), radians = angle * Math.PI / 180;
const front = angle > 90 ? "A" : "B", rear = front === "A" ? "B" : "A";
angleOutput.textContent = angle + "° · " + (angle === 90 ? "edge-on" : front + " in front");
const pixels: Uint8Array[] = [];
for (const [index, view] of views.entries()) {
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 500);
camera.position.set(Math.sin(radians) * 80, 20, view.targetZ + Math.cos(radians) * 80);
camera.lookAt(0, 0, view.targetZ);
const renderer = renderers[index];
renderer.render(view.scene, camera);
const gl = renderer.getContext(), data = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data);
if (gl.getError() !== gl.NO_ERROR) throw new Error("WebGL error while drawing the panels");
pixels.push(data);
}
let differentPixels = 0;
for (let i = 0; i < pixels[0].length; i += 4) {
if ([0, 1, 2].some(channel => Math.abs(pixels[0][i + channel] - pixels[1][i + channel]) > RGB_TOLERANCE)) differentPixels++;
}
const differs = differentPixels > MIN_DIFFERING_PIXELS;
status.textContent = differs ? "Issue visible: part of the rear panel is missing in the merged section."
: "These views match at this angle.";
explanation.textContent = differs ? "Look inside panel " + front + ": panel " + rear
+ "’s letter and grid contribute in the comparison, but are hidden in the merged section."
: "Both views include the same panel detail. Moving the camera changes which panel is nearer; reversing placement changes the draw order.";
measurements.textContent = differentPixels.toLocaleString() + ` pixels differ between the views (RGB tolerance: ${RGB_TOLERANCE}/255).`;
return differentPixels;
}

async function release(items: View[]) {
for (const view of items) {
await view.world.clear();
view.grid.dispose();
}
}

async function refresh() {
controls.forEach(control => { control.disabled = true; });
status.textContent = "Loading the two panels…";
const next: View[] = [];
try {
for (const split of [false, true]) {
const fixture = await createFixture(split, reversed);
const grid = new THREE.GridHelper(64, 8, 0x71808c, 0x39434f);
grid.position.set(0, -12, fixture.targetZ);
fixture.scene.add(grid);
fixture.scene.background = new THREE.Color(0x151b23);
next.push({ ...fixture, grid });
}
if (meshCount(next[0].scene) !== 1 || meshCount(next[1].scene) !== 2) {
throw new Error("Expected one merged mesh and two separate meshes");
}
await release(views);
views = next;
order.textContent = reversed ? "Placement: B, then A" : "Placement: A, then B";
return draw();
} catch (error) {
if (views !== next) await release(next);
throw error;
} finally {
controls.forEach(control => { control.disabled = false; });
}
}

async function sweep() {
const originalAngle = slider.value, originalReversed = reversed;
const failures: { angle: number, reversed: boolean, differentPixels: number }[] = [];
for (const placementOrder of [false, true]) {
if (reversed !== placementOrder) {
reversed = placementOrder;
await refresh();
}
for (const preset of document.querySelectorAll<HTMLButtonElement>("[data-angle]")) {
slider.value = preset.dataset.angle!;
const differentPixels = draw()!;
if (differentPixels > MIN_DIFFERING_PIXELS) failures.push({ angle: Number(slider.value), reversed, differentPixels });
}
}
slider.value = originalAngle;
reversed = originalReversed;
await refresh();
summary.textContent = failures.length
? "FAIL: the merged section hides panel detail at " + failures.map(failure => `${failure.angle}° (${failure.reversed ? "B, then A" : "A, then B"})`).join(", ") + "."
: "PASS: the merged section matches the separate sections at every preset in both placement orders.";
return { passed: failures.length === 0, failures };
}

function reload(runSweep = false) {
const loading = refresh().then(() => runSweep ? sweep() : undefined).catch(error => {
status.textContent = "Could not render the comparison: " + (error instanceof Error ? error.message : String(error));
return { error: status.textContent };
});
if (runSweep) window["transparencyTest"] = loading;
}

try {
installFixtureAssets();
for (const id of ["merged", "separate"]) {
const renderer = new THREE.WebGLRenderer({ alpha: false, antialias: true });
renderer.setSize(width, height, false);
renderer.domElement.setAttribute("aria-label", id === "merged" ? "Merged translucent panels" : "Separate translucent panels for comparison");
document.getElementById(id)!.append(renderer.domElement);
renderers.push(renderer);
}
slider.addEventListener("input", draw);
for (const preset of document.querySelectorAll<HTMLButtonElement>("[data-angle]")) {
preset.addEventListener("click", () => { slider.value = preset.dataset.angle!; draw(); });
}
button.addEventListener("click", () => { reversed = !reversed; reload(); });
window.addEventListener("pagehide", event => {
if (event.persisted) return;
void release(views);
for (const renderer of renderers) renderer.dispose();
AssetLoader.removeSource("transparency-fixture");
Caching.clear();
shutdown();
});
reload(true);
} catch (error) {
status.textContent = "Could not start the comparison: " + (error instanceof Error ? error.message : String(error));
window["transparencyTest"] = Promise.resolve({ error: status.textContent });
}
Loading