mirror of
https://github.com/TomHodson/tomhodson.github.com.git
synced 2025-06-26 10:01:18 +02:00
302 lines
9.6 KiB
JavaScript
302 lines
9.6 KiB
JavaScript
import * as THREE from "three";
|
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
|
import { Timer } from "three/addons/Addons.js";
|
|
import { GUI } from "lil-gui";
|
|
import { vertexShader, fragmentShader } from "./VolumeShaders.js";
|
|
|
|
import {
|
|
componentHTML,
|
|
setupThreeJS,
|
|
deserialiseCamera,
|
|
deserialiseControls,
|
|
} from "./helpers.js";
|
|
|
|
// See https://stackoverflow.com/questions/62003464/what-is-relation-between-type-and-format-of-texture
|
|
// https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html
|
|
const dtypes = {
|
|
uint8: {
|
|
internalFormat: "R8",
|
|
format: THREE.RedFormat,
|
|
type: THREE.UnsignedByteType,
|
|
array_type: Uint8Array,
|
|
},
|
|
float16: {
|
|
internalFormat: "R16F",
|
|
format: THREE.RedFormat,
|
|
type: THREE.HalfFloatType,
|
|
array_type: Uint16Array,
|
|
},
|
|
float32: {
|
|
internalFormat: "R32F",
|
|
format: THREE.RedFormat,
|
|
type: THREE.FloatType,
|
|
array_type: Float32Array,
|
|
},
|
|
};
|
|
|
|
async function load_metadata(metadata_path) {
|
|
console.log("Loading metadata from", metadata_path);
|
|
const metadata_res = await fetch(metadata_path);
|
|
return await metadata_res.json();
|
|
}
|
|
|
|
async function load_model_compressed_bytes(model_path) {
|
|
const model_response = await fetch(model_path);
|
|
const ds = new DecompressionStream("gzip");
|
|
const blob_in = await model_response.blob();
|
|
console.log("Compressed Model size", blob_in.size);
|
|
const stream_in = blob_in.stream().pipeThrough(ds);
|
|
const buffer = await new Response(stream_in).arrayBuffer();
|
|
console.log("Decompressed Model size", buffer.byteLength);
|
|
return buffer;
|
|
}
|
|
|
|
async function load_model_bytes_gzip(model_path, metadata_path) {
|
|
const [metadata, model_buffer] = await Promise.all([
|
|
load_metadata(metadata_path),
|
|
load_model_compressed_bytes(model_path),
|
|
]);
|
|
|
|
const array_type = dtypes[metadata.dtype].array_type;
|
|
return [metadata, new array_type(model_buffer)];
|
|
}
|
|
|
|
async function load_model(model_path, metadata_path, scene) {
|
|
// If the model path ends in ".gz", we assume that the model is compressed.
|
|
const [metadata, model_data] = await load_model_bytes_gzip(
|
|
model_path,
|
|
metadata_path
|
|
);
|
|
|
|
console.log("Loaded model with metadata", metadata);
|
|
console.log("Model shape", metadata.shape);
|
|
console.log("Model dtype", metadata.dtype);
|
|
|
|
const texture = new THREE.Data3DTexture(
|
|
model_data, // The data values stored in the pixels of the texture.
|
|
metadata.shape[2], // Width of texture.
|
|
metadata.shape[1], // Height of texture.
|
|
metadata.shape[0] // Depth of texture.
|
|
);
|
|
texture.internalFormat = dtypes[metadata.dtype].internalFormat;
|
|
texture.format = dtypes[metadata.dtype].format;
|
|
texture.type = dtypes[metadata.dtype].type;
|
|
|
|
texture.minFilter = THREE.LinearFilter; // Linear filter for minification.
|
|
texture.magFilter = THREE.LinearFilter; // Linear filter for maximization.
|
|
// texture.minFilter = THREE.NearestFilter; // Nearest filter for minification.
|
|
// texture.magFilter = THREE.NearestFilter; // Nearest filter for maximization.
|
|
|
|
// Repeat edge values when sampling outside of texture boundaries.
|
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
texture.wrapR = THREE.ClampToEdgeWrapping;
|
|
|
|
// Mark texture for update so that the changes take effect.
|
|
texture.needsUpdate = true;
|
|
|
|
return { texture, metadata };
|
|
}
|
|
|
|
function make_box() {
|
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
const box = new THREE.Mesh(geometry);
|
|
box.scale.set(1, 1, 1);
|
|
// box.scale.set(dataDescription.scale[0], dataDescription.scale[1], dataDescription.scale[2]);
|
|
|
|
const line = new THREE.LineSegments(
|
|
new THREE.EdgesGeometry(geometry),
|
|
new THREE.LineBasicMaterial({ color: 0x999999 })
|
|
);
|
|
box.add(line);
|
|
return box;
|
|
}
|
|
|
|
function volumeMaterial(texture, renderProps) {
|
|
return new THREE.RawShaderMaterial({
|
|
glslVersion: THREE.GLSL3, // Shader language version.
|
|
uniforms: {
|
|
dataTexture: { value: texture }, // Volume data texture.
|
|
// colorTexture: { value: colorTexture }, // Color palette texture.
|
|
renderMode: { value: renderProps.renderMode }, // Rendering mode.
|
|
cameraPosition: { value: new THREE.Vector3() }, // Current camera position.
|
|
samplingRate: { value: renderProps.samplingRate }, // Sampling rate of the volume.
|
|
|
|
clampMin: { value: renderProps.clampMin }, // Clamp values below this value to 0.
|
|
clampMax: { value: renderProps.clampMax }, // Clamp values above this value to 1.
|
|
|
|
iso_threshold: { value: renderProps.iso_threshold }, // Threshold for adjusting volume rendering.
|
|
iso_width: { value: renderProps.iso_width }, // Threshold for adjusting volume rendering.
|
|
|
|
alphaScale: { value: renderProps.alphaScale }, // Alpha scale of volume rendering.
|
|
invertColor: { value: renderProps.invertColor }, // Invert color palette.
|
|
},
|
|
vertexShader: vertexShader, // Vertex shader code.
|
|
fragmentShader: fragmentShader, // Fragment shader code.
|
|
side: THREE.BackSide, // Render only back-facing triangles of box geometry.
|
|
transparent: true, // Use alpha channel / alpha blending when rendering.
|
|
});
|
|
}
|
|
|
|
export class VolumeViewer extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.isVisible = true;
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
const { container, canvas, scene, renderer, gui, gui_buttons } =
|
|
setupThreeJS(this);
|
|
|
|
const model = this.getAttribute("model");
|
|
const model_metadata = this.getAttribute("model-metadata");
|
|
|
|
// Make a box that just holds some triangles that our shader will render onto.
|
|
const box = make_box();
|
|
scene.add(box);
|
|
|
|
const renderModes = {
|
|
"Max Intensity": 0,
|
|
"Mean Intensity": 1,
|
|
"Min Intensity": 2,
|
|
Isosurface: 3,
|
|
};
|
|
|
|
let material = null;
|
|
load_model(model, model_metadata, scene).then(({ texture, metadata }) => {
|
|
// Create the custom material with attached shaders.
|
|
material = volumeMaterial(texture, presets.Default);
|
|
box.material = material;
|
|
gui
|
|
.add(material.uniforms.renderMode, "value", renderModes)
|
|
.name("Render Mode");
|
|
gui
|
|
.add(material.uniforms.samplingRate, "value", 0.1, 2.0, 0.1)
|
|
.name("Sampling Rate");
|
|
gui
|
|
.add(material.uniforms.clampMin, "value", 0.0, 1.0, 0.01)
|
|
.name("Clamp Min");
|
|
gui
|
|
.add(material.uniforms.clampMax, "value", 0.0, 1.0, 0.01)
|
|
.name("Clamp Max");
|
|
gui
|
|
.add(material.uniforms.iso_threshold, "value", 0.0, 1.0, 0.01)
|
|
.name("Isosurface Threshold");
|
|
gui
|
|
.add(material.uniforms.iso_width, "value", 0.0, 0.05, 0.001)
|
|
.name("Isosurface Width");
|
|
gui
|
|
.add(material.uniforms.alphaScale, "value", 0.1, 2.0, 0.1)
|
|
.name("Alpha Scale");
|
|
gui.add(material.uniforms.invertColor, "value").name("Invert Color");
|
|
});
|
|
|
|
const presets = {
|
|
Default: {
|
|
renderMode: 0,
|
|
samplingRate: 1.0,
|
|
clampMin: 0.0,
|
|
clampMax: 1.0,
|
|
iso_threshold: 0.1,
|
|
iso_width: 0.01,
|
|
alphaScale: 1.0,
|
|
invertColor: false,
|
|
},
|
|
"Air Pockets": {
|
|
alphaScale: 2,
|
|
clampMax: 1,
|
|
clampMin: 0,
|
|
invertColor: false,
|
|
iso_threshold: 0.06,
|
|
iso_width: 0.002,
|
|
renderMode: 3,
|
|
samplingRate: 1,
|
|
},
|
|
};
|
|
|
|
// Add a button to print the current settings to the console
|
|
gui_buttons
|
|
.add(
|
|
{
|
|
printSettings: () =>
|
|
console.log(
|
|
Object.fromEntries(
|
|
Object.keys(presets.Default).map((key) => [
|
|
key,
|
|
material?.uniforms[key]?.value,
|
|
])
|
|
)
|
|
),
|
|
},
|
|
"printSettings"
|
|
)
|
|
.name("Print Current Settings");
|
|
|
|
// Add a dropdown to select a preset
|
|
let renderProps = {
|
|
presets: "Default",
|
|
};
|
|
gui
|
|
.add(renderProps, "preset", presets)
|
|
.setValue("Default")
|
|
.onChange((preset) => {
|
|
Object.keys(preset).forEach((key) => {
|
|
if (material.uniforms[key]) {
|
|
material.uniforms[key].value = preset[key];
|
|
} else {
|
|
console.warn(`No uniform found for ${key}`);
|
|
}
|
|
});
|
|
gui.controllers.forEach((control) => control.updateDisplay());
|
|
})
|
|
.name("Presets");
|
|
|
|
const render = () => renderer.render(scene, this.camera);
|
|
this.render = render;
|
|
|
|
// --- OrbitControls ---
|
|
this.controls = new OrbitControls(this.camera, renderer.domElement);
|
|
this.controls.addEventListener("change", render);
|
|
this.controls.enableDamping = true;
|
|
this.controls.dampingFactor = 0.25;
|
|
this.controls.autoRotate = true;
|
|
deserialiseControls(this);
|
|
|
|
canvas.addEventListener("click", () => {
|
|
this.controls.autoRotate = false;
|
|
});
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
|
scene.add(ambientLight);
|
|
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
|
dirLight.position.set(5, 5, 10);
|
|
scene.add(dirLight);
|
|
|
|
window.addEventListener("resize", this.onWindowResize, false);
|
|
|
|
this.onWindowResize = () => {
|
|
this.camera.aspect = canvas.clientWidth / canvas.clientHeight;
|
|
this.camera.updateProjectionMatrix();
|
|
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
|
};
|
|
this.onWindowResize();
|
|
|
|
const timer = new Timer();
|
|
|
|
const update = () => {
|
|
if (this.isVisible) {
|
|
timer.update();
|
|
const delta = timer.getDelta();
|
|
this.controls.update(delta);
|
|
if (material)
|
|
box.material.uniforms.cameraPosition.value.copy(this.camera.position);
|
|
this.render();
|
|
requestAnimationFrame(update);
|
|
}
|
|
};
|
|
update();
|
|
}
|
|
}
|