EG/templates/webgl/viewer.js
2026-03-06 09:52:57 +08:00

387 lines
12 KiB
JavaScript

const statusEl = document.getElementById("status");
const canvas = document.getElementById("scene-canvas");
function setStatus(message, level = "warn") {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = `status ${level}`;
}
function rowMajorToMatrix4(THREE, m) {
const mat = new THREE.Matrix4();
mat.set(
m[0], m[1], m[2], m[3],
m[4], m[5], m[6], m[7],
m[8], m[9], m[10], m[11],
m[12], m[13], m[14], m[15],
);
return mat;
}
function pandaRowMajorToMatrix4(THREE, m) {
const mat = new THREE.Matrix4();
// Panda's matrix data uses row-vector convention (translation on last row).
// Three.js expects column-vector convention (translation on last column).
mat.set(
m[0], m[4], m[8], m[12],
m[1], m[5], m[9], m[13],
m[2], m[6], m[10], m[14],
m[3], m[7], m[11], m[15],
);
return mat;
}
function convertNodeMatrix(THREE, sourceMatRowMajor, basis, basisInv, matrixConvention = "panda_row_vector_row_major") {
const src = matrixConvention === "panda_row_vector_row_major"
? pandaRowMajorToMatrix4(THREE, sourceMatRowMajor)
: rowMajorToMatrix4(THREE, sourceMatRowMajor);
return basis.clone().multiply(src).multiply(basisInv);
}
function toColorArray(color, fallback = [1, 1, 1]) {
if (!Array.isArray(color) || color.length < 3) return fallback;
return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
}
function applyMaterialOverride(THREE, root, override) {
if (!override) return;
root.traverse((obj) => {
if (!obj.isMesh || !obj.material) return;
const list = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of list) {
if (mat.color && Array.isArray(override.base_color)) {
const [r, g, b] = override.base_color;
mat.color.setRGB(r ?? 1, g ?? 1, b ?? 1);
}
if (Object.prototype.hasOwnProperty.call(override, "roughness") && "roughness" in mat) {
mat.roughness = Number(override.roughness);
}
if (Object.prototype.hasOwnProperty.call(override, "metallic") && "metalness" in mat) {
mat.metalness = Number(override.metallic);
}
if (Object.prototype.hasOwnProperty.call(override, "opacity")) {
const opacity = THREE.MathUtils.clamp(Number(override.opacity), 0, 1);
const isTransparent = opacity < 0.999;
mat.opacity = opacity;
mat.transparent = isTransparent;
// Prevent "see-through solid mesh" when source GLTF had transparent pipeline state.
mat.depthWrite = !isTransparent;
mat.depthTest = true;
mat.blending = isTransparent ? THREE.NormalBlending : THREE.NoBlending;
if (!isTransparent && "alphaTest" in mat) {
mat.alphaTest = 0;
}
if (!isTransparent && "transmission" in mat) {
mat.transmission = 0;
}
}
mat.needsUpdate = true;
}
});
}
function textureSlotByStage(stageName) {
const key = String(stageName || "").toLowerCase();
if (key.includes("normal")) return "normalMap";
if (key.includes("rough")) return "roughnessMap";
if (key.includes("metal")) return "metalnessMap";
if (key.includes("emission") || key.includes("emissive")) return "emissiveMap";
if (key.includes("ao")) return "aoMap";
if (key.includes("alpha") || key.includes("opacity")) return "alphaMap";
return "map";
}
function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) {
if (!Array.isArray(textureOverrides) || textureOverrides.length === 0) return;
const texBySlot = new Map();
for (const item of textureOverrides) {
if (!item || !item.uri) continue;
const slot = textureSlotByStage(item.stage);
if (texBySlot.has(slot)) continue;
try {
const tex = textureLoader.load(item.uri);
tex.flipY = false;
texBySlot.set(slot, tex);
} catch (err) {
console.warn("Texture load failed:", item.uri, err);
}
}
if (texBySlot.size === 0) return;
root.traverse((obj) => {
if (!obj.isMesh || !obj.material) return;
const list = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of list) {
for (const [slot, tex] of texBySlot.entries()) {
if (slot in mat) {
mat[slot] = tex;
}
}
mat.needsUpdate = true;
}
});
}
function directionToThree(THREE, direction, basis) {
const d = new THREE.Vector3(
Number(direction?.[0] ?? 0),
Number(direction?.[1] ?? 0),
Number(direction?.[2] ?? -1),
);
d.applyMatrix4(basis);
if (d.lengthSq() < 1e-6) d.set(0, 0, -1);
return d.normalize();
}
async function bootstrap() {
setStatus("Loading WebGL dependencies...");
let THREE;
let OrbitControls;
let GLTFLoader;
try {
THREE = await import("../vendor/three.module.min.js");
({ OrbitControls } = await import("../vendor/OrbitControls.js"));
({ GLTFLoader } = await import("../vendor/GLTFLoader.js"));
} catch (err) {
setStatus(
[
"Failed to load local Three.js vendor files.",
"Please replace vendor placeholders with official files:",
"- vendor/three.module.min.js",
"- vendor/OrbitControls.js",
"- vendor/GLTFLoader.js",
"",
String(err),
].join("\n"),
"error",
);
throw err;
}
setStatus("Loading scene manifest...");
const response = await fetch("../scene/scene_webgl.json", { cache: "no-cache" });
if (!response.ok) {
throw new Error(`Failed to load scene manifest: HTTP ${response.status}`);
}
const data = await response.json();
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: false,
});
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x11151c);
const cameraData = data.camera || {};
const camera = new THREE.PerspectiveCamera(
Number(cameraData.fov_deg ?? 80),
window.innerWidth / Math.max(1, window.innerHeight),
Number(cameraData.near ?? 0.1),
Number(cameraData.far ?? 10000),
);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0, 0);
const basis = rowMajorToMatrix4(
THREE,
Array.isArray(data.coordinate?.basis_matrix)
? data.coordinate.basis_matrix
: [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1],
);
const basisInv = basis.clone().invert();
const matrixConvention = String(data.coordinate?.matrix_convention || "panda_row_vector_row_major");
if (Array.isArray(cameraData.matrix_local_row_major) && cameraData.matrix_local_row_major.length === 16) {
const camMat = convertNodeMatrix(
THREE,
cameraData.matrix_local_row_major,
basis,
basisInv,
matrixConvention,
);
camera.matrix.copy(camMat);
camera.matrix.decompose(camera.position, camera.quaternion, camera.scale);
camera.matrixAutoUpdate = true;
camera.updateMatrix();
} else {
camera.position.set(0, -50, 20);
camera.lookAt(0, 0, 0);
}
const env = data.environment || {};
if (env.ambient_light) {
const c = toColorArray(env.ambient_light.color, [0.2, 0.2, 0.2]);
const amb = new THREE.AmbientLight(new THREE.Color(c[0], c[1], c[2]), Number(env.ambient_light.intensity ?? 1));
scene.add(amb);
}
if (env.directional_light) {
const c = toColorArray(env.directional_light.color, [0.8, 0.8, 0.8]);
const dirLight = new THREE.DirectionalLight(
new THREE.Color(c[0], c[1], c[2]),
Number(env.directional_light.intensity ?? 1),
);
const dir = directionToThree(THREE, env.directional_light.direction, basis);
dirLight.position.copy(dir.clone().multiplyScalar(-40));
dirLight.target.position.set(0, 0, 0);
scene.add(dirLight);
scene.add(dirLight.target);
}
const nodeMap = new Map();
const pendingModelLoads = [];
const gltfLoader = new GLTFLoader();
const textureLoader = new THREE.TextureLoader();
for (const node of Array.isArray(data.nodes) ? data.nodes : []) {
let obj;
if (node.kind === "point_light") {
const light = node.light || {};
const c = toColorArray(light.color, [1, 1, 1]);
obj = new THREE.PointLight(
new THREE.Color(c[0], c[1], c[2]),
Number(light.intensity ?? 1),
Number(light.range ?? 0),
);
} else if (node.kind === "spot_light") {
const light = node.light || {};
const c = toColorArray(light.color, [1, 1, 1]);
const angle = THREE.MathUtils.degToRad(Number(light.spot_angle_deg ?? 45));
const spot = new THREE.SpotLight(
new THREE.Color(c[0], c[1], c[2]),
Number(light.intensity ?? 1),
Number(light.range ?? 0),
angle,
1 - Number(light.inner_cone_ratio ?? 0.4),
);
spot.target.position.set(0, 0, -1);
obj = spot;
} else if (node.kind === "ground") {
const g = node.ground || {};
const width = Number(g.width ?? 100);
const height = Number(g.height ?? 100);
const m = node.material_override || {};
const bc = Array.isArray(m.base_color) ? m.base_color : [0.8, 0.8, 0.8, 1];
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(Number(bc[0] ?? 0.8), Number(bc[1] ?? 0.8), Number(bc[2] ?? 0.8)),
roughness: Number(m.roughness ?? 1),
metalness: Number(m.metallic ?? 0),
transparent: Number(m.opacity ?? 1) < 1,
opacity: Number(m.opacity ?? 1),
side: THREE.DoubleSide,
});
obj = new THREE.Mesh(new THREE.PlaneGeometry(width, height), mat);
obj.receiveShadow = true;
} else {
obj = new THREE.Group();
const modelUri = node.model?.uri;
if (modelUri) {
const p = new Promise((resolve) => {
gltfLoader.load(
modelUri,
(gltf) => {
const root = gltf.scene || (Array.isArray(gltf.scenes) ? gltf.scenes[0] : null);
if (root) {
applyMaterialOverride(THREE, root, node.material_override || null);
applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader);
obj.add(root);
}
resolve();
},
undefined,
(err) => {
console.warn(`Failed to load model ${modelUri}:`, err);
resolve();
},
);
});
pendingModelLoads.push(p);
}
}
obj.name = node.name || node.id || "node";
if (Array.isArray(node.matrix_local_row_major) && node.matrix_local_row_major.length === 16) {
const converted = convertNodeMatrix(THREE, node.matrix_local_row_major, basis, basisInv, matrixConvention);
obj.matrixAutoUpdate = false;
obj.matrix.copy(converted);
obj.matrix.decompose(obj.position, obj.quaternion, obj.scale);
}
nodeMap.set(node.id, obj);
}
for (const node of Array.isArray(data.nodes) ? data.nodes : []) {
const obj = nodeMap.get(node.id);
if (!obj) continue;
const parent = node.parent_id ? nodeMap.get(node.parent_id) : null;
if (parent) {
parent.add(obj);
} else {
scene.add(obj);
}
if (obj.isSpotLight && obj.target) {
obj.add(obj.target);
}
}
await Promise.all(pendingModelLoads);
const resize = () => {
const w = window.innerWidth;
const h = Math.max(1, window.innerHeight);
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener("resize", resize);
resize();
setStatus(
`Scene ready. Nodes: ${(data.nodes || []).length}.\nUse mouse to orbit, wheel to zoom.`,
"ok",
);
const clock = new THREE.Clock();
const tick = () => {
requestAnimationFrame(tick);
const dt = clock.getDelta();
if (dt >= 0) {
controls.update();
renderer.render(scene, camera);
}
};
tick();
}
bootstrap().catch((err) => {
console.error(err);
setStatus(`Viewer bootstrap failed:\n${String(err)}`, "error");
});