| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; |
| import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm'; |
| import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/webgpu/+esm'; |
|
|
| const MODEL_ROOT = 'https://huggingface.co/needle-tools/SF3D-webgpu/resolve/main/'; |
| const SAMPLE_URL = 'https://huggingface.co/spaces/stabilityai/stable-fast-3d/resolve/main/demo_files/examples/axe.png'; |
| const INPUT_SIZE = 512; |
| const GRID_RESOLUTION = 160; |
| const ISO_THRESHOLD = 10; |
| const TRIPLANE_CHANNELS = 40; |
| const TRIPLANE_SIZE = 384; |
| const DECODE_BATCH = 16384; |
| const BACKGROUND_COLOR = 0.5; |
|
|
| const el = (id) => document.getElementById(id); |
| const ui = { |
| file: el('image-file'), |
| dropzone: el('dropzone'), |
| previewWrap: el('preview-wrap'), |
| preview: el('input-preview'), |
| previewName: el('preview-name'), |
| previewSize: el('preview-size'), |
| sample: el('sample-button'), |
| removeBackground: el('remove-background'), |
| generate: el('generate-button'), |
| generateLabel: el('generate-label'), |
| webgpuDot: el('webgpu-dot'), |
| webgpuLabel: el('webgpu-label'), |
| stage: el('viewer-stage'), |
| canvas: el('viewer-canvas'), |
| empty: el('viewer-empty'), |
| progress: el('viewer-progress'), |
| progressPercent: el('progress-percent'), |
| progressTitle: el('progress-title'), |
| progressDetail: el('progress-detail'), |
| error: el('viewer-error'), |
| errorTitle: el('error-title'), |
| errorDetail: el('error-detail'), |
| errorDismiss: el('error-dismiss'), |
| reset: el('reset-view'), |
| stats: el('mesh-stats'), |
| runtime: el('runtime-metric'), |
| vertices: el('vertices-metric'), |
| faces: el('faces-metric'), |
| download: el('download-button'), |
| }; |
|
|
| let selectedFile = null; |
| let selectedObjectUrl = null; |
| let preparedCanvas = null; |
| let backgroundPipeline = null; |
| let sessions = null; |
| let currentMeshObject = null; |
| let currentMeshData = null; |
| let webgpuReady = false; |
|
|
| function setWebGPUStatus(kind, label) { |
| ui.webgpuDot.className = `status-dot ${kind}`; |
| ui.webgpuLabel.textContent = label; |
| } |
|
|
| function setProgress(title, detail, percent = null) { |
| ui.progressTitle.textContent = title; |
| ui.progressDetail.textContent = detail; |
| if (percent === null) { |
| ui.progressPercent.textContent = '…'; |
| } else { |
| ui.progressPercent.textContent = `${Math.round(percent)}%`; |
| } |
| } |
|
|
| function showProgress(visible) { |
| ui.progress.classList.toggle('is-hidden', !visible); |
| ui.empty.classList.toggle('is-hidden', visible || Boolean(currentMeshObject)); |
| } |
|
|
| function showError(title, detail) { |
| ui.errorTitle.textContent = title; |
| ui.errorDetail.textContent = detail; |
| ui.error.classList.remove('is-hidden'); |
| showProgress(false); |
| } |
|
|
| function clearError() { |
| ui.error.classList.add('is-hidden'); |
| } |
|
|
| function formatBytes(bytes) { |
| if (!Number.isFinite(bytes) || bytes <= 0) return '—'; |
| const units = ['B', 'KB', 'MB', 'GB']; |
| const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); |
| return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; |
| } |
|
|
| function formatDuration(ms) { |
| return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`; |
| } |
|
|
| function isTransparentCanvas(canvas) { |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); |
| for (let i = 3; i < data.length; i += 4) { |
| if (data[i] < 250) return true; |
| } |
| return false; |
| } |
|
|
| function loadImageFromBlob(blob) { |
| return new Promise((resolve, reject) => { |
| const url = URL.createObjectURL(blob); |
| const image = new Image(); |
| image.onload = () => { |
| URL.revokeObjectURL(url); |
| resolve(image); |
| }; |
| image.onerror = () => { |
| URL.revokeObjectURL(url); |
| reject(new Error('The selected file could not be decoded as an image.')); |
| }; |
| image.src = url; |
| }); |
| } |
|
|
| function imageToCanvas(image, maxSize = 1200) { |
| const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height)); |
| const canvas = document.createElement('canvas'); |
| canvas.width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale)); |
| canvas.height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale)); |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| ctx.drawImage(image, 0, 0, canvas.width, canvas.height); |
| return canvas; |
| } |
|
|
| function rawImageToCanvas(rawImage) { |
| const canvas = document.createElement('canvas'); |
| canvas.width = rawImage.width; |
| canvas.height = rawImage.height; |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| const rgba = new Uint8ClampedArray(canvas.width * canvas.height * 4); |
| const source = rawImage.data; |
| const channels = rawImage.channels || 4; |
| for (let i = 0, j = 0; i < source.length; i += channels, j += 4) { |
| rgba[j] = source[i]; |
| rgba[j + 1] = source[i + 1] ?? source[i]; |
| rgba[j + 2] = source[i + 2] ?? source[i]; |
| rgba[j + 3] = channels >= 4 ? source[i + 3] : 255; |
| } |
| ctx.putImageData(new ImageData(rgba, canvas.width, canvas.height), 0, 0); |
| return canvas; |
| } |
|
|
| async function getBackgroundPipeline() { |
| if (!backgroundPipeline) { |
| setProgress('Loading cleanup model', 'Transformers.js is downloading MODNet for this browser.', null); |
| backgroundPipeline = await pipeline('background-removal', 'Xenova/modnet', { |
| device: 'webgpu', |
| dtype: 'fp16', |
| }); |
| } |
| return backgroundPipeline; |
| } |
|
|
| async function prepareSourceCanvas(file) { |
| const image = await loadImageFromBlob(file); |
| const original = imageToCanvas(image); |
| if (!ui.removeBackground.checked || isTransparentCanvas(original)) { |
| return original; |
| } |
|
|
| let blobUrl = null; |
| try { |
| const remover = await getBackgroundPipeline(); |
| blobUrl = URL.createObjectURL(file); |
| const output = await remover(blobUrl); |
| const first = Array.isArray(output) ? output[0] : output; |
| if (!first?.data || !first.width || !first.height) { |
| throw new Error('The cleanup model returned an empty image.'); |
| } |
| return rawImageToCanvas(first); |
| } catch (error) { |
| console.warn('Background cleanup skipped:', error); |
| setProgress('Using original image', 'Background cleanup was skipped; continuing with the uploaded pixels.', 5); |
| return original; |
| } finally { |
| if (blobUrl) URL.revokeObjectURL(blobUrl); |
| } |
| } |
|
|
| function alphaBounds(canvas) { |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height); |
| let minX = width; |
| let minY = height; |
| let maxX = -1; |
| let maxY = -1; |
| for (let y = 0; y < height; y += 1) { |
| for (let x = 0; x < width; x += 1) { |
| if (data[(y * width + x) * 4 + 3] > 8) { |
| minX = Math.min(minX, x); |
| minY = Math.min(minY, y); |
| maxX = Math.max(maxX, x); |
| maxY = Math.max(maxY, y); |
| } |
| } |
| } |
| if (maxX < 0) return { x: 0, y: 0, width, height }; |
| return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }; |
| } |
|
|
| function prepareModelCanvas(source, ratio = 0.85) { |
| const bounds = alphaBounds(source); |
| const side = Math.max(bounds.width, bounds.height) / ratio; |
| const centerX = bounds.x + bounds.width / 2; |
| const centerY = bounds.y + bounds.height / 2; |
| const cropX = centerX - side / 2; |
| const cropY = centerY - side / 2; |
| const canvas = document.createElement('canvas'); |
| canvas.width = INPUT_SIZE; |
| canvas.height = INPUT_SIZE; |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| ctx.clearRect(0, 0, INPUT_SIZE, INPUT_SIZE); |
| ctx.imageSmoothingEnabled = true; |
| ctx.imageSmoothingQuality = 'high'; |
| ctx.drawImage(source, cropX, cropY, side, side, 0, 0, INPUT_SIZE, INPUT_SIZE); |
| return canvas; |
| } |
|
|
| function renderInputPreview(canvas, name, bytes) { |
| if (selectedObjectUrl) URL.revokeObjectURL(selectedObjectUrl); |
| canvas.toBlob((blob) => { |
| if (!blob) return; |
| selectedObjectUrl = URL.createObjectURL(blob); |
| ui.preview.src = selectedObjectUrl; |
| }, 'image/png'); |
| ui.previewName.textContent = name; |
| ui.previewSize.textContent = formatBytes(bytes); |
| ui.previewWrap.classList.remove('is-hidden'); |
| } |
|
|
| function selectFile(file) { |
| if (!file || !file.type.startsWith('image/')) { |
| showError('Unsupported file', 'Choose a PNG, JPEG, or WebP image.'); |
| return; |
| } |
| selectedFile = file; |
| clearError(); |
| preparedCanvas = null; |
| ui.generate.disabled = !webgpuReady; |
| ui.generateLabel.textContent = webgpuReady ? 'Generate 3D mesh' : 'WebGPU required'; |
| ui.previewWrap.classList.add('is-hidden'); |
| const reader = new FileReader(); |
| reader.onload = () => { |
| ui.preview.src = reader.result; |
| ui.previewName.textContent = file.name; |
| ui.previewSize.textContent = formatBytes(file.size); |
| ui.previewWrap.classList.remove('is-hidden'); |
| }; |
| reader.readAsDataURL(file); |
| } |
|
|
| async function loadSample() { |
| ui.sample.disabled = true; |
| ui.sample.querySelector('span').textContent = 'Loading sample…'; |
| try { |
| const response = await fetch(SAMPLE_URL); |
| if (!response.ok) throw new Error(`Sample request failed (${response.status}).`); |
| const blob = await response.blob(); |
| selectFile(new File([blob], 'axe.png', { type: blob.type || 'image/png' })); |
| } catch (error) { |
| showError('Sample unavailable', error.message); |
| } finally { |
| ui.sample.disabled = false; |
| ui.sample.querySelector('span').textContent = 'Try a sample'; |
| } |
| } |
|
|
| function urlFor(path) { |
| return `${MODEL_ROOT}${path}?download=true`; |
| } |
|
|
| function resolveName(names, preferred, index = 0) { |
| return preferred.find((candidate) => names.includes(candidate)) || names[index]; |
| } |
|
|
| function resolveOutput(outputs, preferred, index = 0) { |
| for (const name of preferred) { |
| if (outputs[name]) return outputs[name]; |
| } |
| const first = Object.keys(outputs)[index]; |
| if (!first) throw new Error('The ONNX graph returned no output tensor.'); |
| return outputs[first]; |
| } |
|
|
| async function createSessions() { |
| if (sessions) return sessions; |
| if (!webgpuReady) throw new Error('WebGPU is not available in this browser.'); |
| if (ort.env?.wasm) { |
| ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/'; |
| ort.env.wasm.numThreads = 1; |
| } |
| const options = { |
| executionProviders: ['webgpu'], |
| graphOptimizationLevel: 'all', |
| enableMemPattern: true, |
| }; |
| setProgress('Loading SF3D graphs', 'Downloading the tokenizer, backbone, and decoder. They will be cached after this run.', null); |
| const [imageTokenizer, backbone, decoder] = await Promise.all([ |
| ort.InferenceSession.create(urlFor('onnx/image_tokenizer_single.onnx'), options), |
| ort.InferenceSession.create(urlFor('onnx/backbone_fp16.onnx'), options), |
| ort.InferenceSession.create(urlFor('onnx/decoder_single.onnx'), options), |
| ]); |
| sessions = { imageTokenizer, backbone, decoder }; |
| return sessions; |
| } |
|
|
| async function fetchBinary(path, label, progressOffset, progressScale) { |
| const response = await fetch(urlFor(path)); |
| if (!response.ok) throw new Error(`Could not load ${label} (${response.status}).`); |
| const total = Number(response.headers.get('content-length')) || 0; |
| const reader = response.body?.getReader(); |
| if (!reader) return response.arrayBuffer(); |
| const chunks = []; |
| let loaded = 0; |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| chunks.push(value); |
| loaded += value.byteLength; |
| if (total) setProgress('Loading mesh grid', `${label} · ${formatBytes(loaded)} / ${formatBytes(total)}`, progressOffset + (loaded / total) * progressScale); |
| } |
| const buffer = new ArrayBuffer(loaded); |
| const output = new Uint8Array(buffer); |
| let offset = 0; |
| for (const chunk of chunks) { |
| output.set(chunk, offset); |
| offset += chunk.byteLength; |
| } |
| return buffer; |
| } |
|
|
| async function loadGrid() { |
| setProgress('Loading mesh grid', 'Fetching the tetrahedral surface grid.', 68); |
| const [verticesBuffer, indicesBuffer] = await Promise.all([ |
| fetchBinary('tets_vertices.bin', 'vertices', 68, 7), |
| fetchBinary('tets_indices.bin', 'tetrahedra', 75, 7), |
| ]); |
| return { |
| vertices: new Float32Array(verticesBuffer), |
| indices: new Uint32Array(indicesBuffer), |
| }; |
| } |
|
|
| function createInputTensors(canvas, imageTokenizer) { |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| const { data } = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE); |
| const rgb = new Float32Array(INPUT_SIZE * INPUT_SIZE * 3); |
| for (let i = 0, j = 0; i < data.length; i += 4, j += 3) { |
| const alpha = data[i + 3] / 255; |
| rgb[j] = BACKGROUND_COLOR * (1 - alpha) + (data[i] / 255) * alpha; |
| rgb[j + 1] = BACKGROUND_COLOR * (1 - alpha) + (data[i + 1] / 255) * alpha; |
| rgb[j + 2] = BACKGROUND_COLOR * (1 - alpha) + (data[i + 2] / 255) * alpha; |
| } |
| const inputNames = imageTokenizer.inputNames; |
| const rgbName = resolveName(inputNames, ['rgb'], 0); |
| const c2wName = resolveName(inputNames, ['c2w'], 1); |
| const intrinsicName = resolveName(inputNames, ['intrinsic_normed'], 2); |
| const focal = 1 / (2 * Math.tan((40 * Math.PI) / 360)); |
| return { |
| [rgbName]: new ort.Tensor('float32', rgb, [1, INPUT_SIZE, INPUT_SIZE, 3]), |
| [c2wName]: new ort.Tensor('float32', new Float32Array([ |
| 0, 0, 1, 1.6, |
| 1, 0, 0, 0, |
| 0, 1, 0, 0, |
| 0, 0, 0, 1, |
| ]), [1, 4, 4]), |
| [intrinsicName]: new ort.Tensor('float32', new Float32Array([ |
| focal, 0, 0.5, |
| 0, focal, 0.5, |
| 0, 0, 1, |
| ]), [1, 3, 3]), |
| }; |
| } |
|
|
| async function decodeGrid(grid, triplane, decoder) { |
| const vertexCount = grid.vertices.length / 3; |
| const density = new Float32Array(vertexCount); |
| const deformation = new Float32Array(vertexCount * 3); |
| const triplaneTensor = new ort.Tensor('float32', triplane, [1, 3, TRIPLANE_CHANNELS, TRIPLANE_SIZE, TRIPLANE_SIZE]); |
| const positions = new Float32Array(DECODE_BATCH * 3); |
| const inputNames = decoder.inputNames; |
| const triplaneName = resolveName(inputNames, ['triplane'], 0); |
| const positionsName = resolveName(inputNames, ['positions'], 1); |
| const densityName = 'density'; |
| const offsetName = 'vertex_offset'; |
| for (let start = 0; start < vertexCount; start += DECODE_BATCH) { |
| const count = Math.min(DECODE_BATCH, vertexCount - start); |
| const currentPositions = positions.subarray(0, count * 3); |
| for (let i = 0; i < count; i += 1) { |
| const base = (start + i) * 3; |
| currentPositions[i * 3] = grid.vertices[base] * 2 - 1; |
| currentPositions[i * 3 + 1] = grid.vertices[base + 1] * 2 - 1; |
| currentPositions[i * 3 + 2] = grid.vertices[base + 2] * 2 - 1; |
| } |
| const outputs = await decoder.run({ |
| [triplaneName]: triplaneTensor, |
| [positionsName]: new ort.Tensor('float32', currentPositions, [1, count, 3]), |
| }); |
| const densityTensor = resolveOutput(outputs, [densityName]); |
| const offsetTensor = resolveOutput(outputs, [offsetName]); |
| for (let i = 0; i < count; i += 1) { |
| density[start + i] = densityTensor.data[i]; |
| deformation[(start + i) * 3] = offsetTensor.data[i * 3]; |
| deformation[(start + i) * 3 + 1] = offsetTensor.data[i * 3 + 1]; |
| deformation[(start + i) * 3 + 2] = offsetTensor.data[i * 3 + 2]; |
| } |
| const percent = 82 + ((start + count) / vertexCount) * 15; |
| setProgress('Decoding surface', `${(start + count).toLocaleString()} / ${vertexCount.toLocaleString()} grid points`, percent); |
| } |
| return { density, deformation }; |
| } |
|
|
| const TRIANGLE_TABLE = [ |
| [-1, -1, -1, -1, -1, -1], [1, 0, 2, -1, -1, -1], [4, 0, 3, -1, -1, -1], [1, 4, 2, 1, 3, 4], |
| [3, 1, 5, -1, -1, -1], [2, 3, 0, 2, 5, 3], [1, 4, 0, 1, 5, 4], [4, 2, 5, -1, -1, -1], |
| [4, 5, 2, -1, -1, -1], [4, 1, 0, 4, 5, 1], [3, 2, 0, 3, 5, 2], [1, 3, 5, -1, -1, -1], |
| [4, 1, 2, 4, 3, 1], [3, 0, 4, -1, -1, -1], [2, 0, 1, -1, -1, -1], [-1, -1, -1, -1, -1, -1], |
| ]; |
| function buildMesh(grid, decoded) { |
| const vertexCount = grid.vertices.length / 3; |
| const tetCount = grid.indices.length / 4; |
| const occupied = new Uint8Array(vertexCount); |
| for (let i = 0; i < vertexCount; i += 1) occupied[i] = decoded.density[i] > ISO_THRESHOLD ? 1 : 0; |
|
|
| const positions = []; |
| const faces = []; |
| const edgeMap = new Map(); |
| const deformed = (index, axis) => { |
| const value = grid.vertices[index * 3 + axis] + (2 / GRID_RESOLUTION) * Math.tanh(decoded.deformation[index * 3 + axis]); |
| return value * 2 - 1; |
| }; |
| const edgeVertex = (a, b) => { |
| if (occupied[a] === occupied[b]) return -1; |
| const low = Math.min(a, b); |
| const high = Math.max(a, b); |
| const key = low * vertexCount + high; |
| const existing = edgeMap.get(key); |
| if (existing !== undefined) return existing; |
| const s0 = decoded.density[a] - ISO_THRESHOLD; |
| const s1 = decoded.density[b] - ISO_THRESHOLD; |
| const denominator = s0 - s1 || 1e-6; |
| const t = Math.max(0, Math.min(1, s0 / denominator)); |
| const x = deformed(a, 0) * (1 - t) + deformed(b, 0) * t; |
| const y = deformed(a, 1) * (1 - t) + deformed(b, 1) * t; |
| const z = deformed(a, 2) * (1 - t) + deformed(b, 2) * t; |
| const index = positions.length / 3; |
| positions.push(x, y, z); |
| edgeMap.set(key, index); |
| return index; |
| }; |
|
|
| for (let tet = 0; tet < tetCount; tet += 1) { |
| const offset = tet * 4; |
| const c0 = grid.indices[offset]; |
| const c1 = grid.indices[offset + 1]; |
| const c2 = grid.indices[offset + 2]; |
| const c3 = grid.indices[offset + 3]; |
| const mask = occupied[c0] | (occupied[c1] << 1) | (occupied[c2] << 2) | (occupied[c3] << 3); |
| if (mask === 0 || mask === 15) continue; |
| const edgeIndices = new Int32Array(6); |
| edgeIndices[0] = edgeVertex(c0, c1); |
| edgeIndices[1] = edgeVertex(c0, c2); |
| edgeIndices[2] = edgeVertex(c0, c3); |
| edgeIndices[3] = edgeVertex(c1, c2); |
| edgeIndices[4] = edgeVertex(c1, c3); |
| edgeIndices[5] = edgeVertex(c2, c3); |
| const table = TRIANGLE_TABLE[mask]; |
| for (let i = 0; i < 6 && table[i] !== -1; i += 3) { |
| faces.push(edgeIndices[table[i]], edgeIndices[table[i + 1]], edgeIndices[table[i + 2]]); |
| } |
| } |
| if (!faces.length) throw new Error('No surface was found. Try a clearer image with one centered object.'); |
| return { positions: new Float32Array(positions), faces: new Uint32Array(faces) }; |
| } |
|
|
| function initViewer() { |
| const scene = new THREE.Scene(); |
| const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100); |
| camera.position.set(0, 0.15, 3.4); |
| const renderer = new THREE.WebGLRenderer({ canvas: ui.canvas, antialias: true, alpha: true }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 1.15; |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.07; |
| controls.minDistance = 1.2; |
| controls.maxDistance = 7; |
| controls.target.set(0, 0, 0); |
| scene.add(new THREE.HemisphereLight(0xdcd7ff, 0x1f2330, 2.1)); |
| const keyLight = new THREE.DirectionalLight(0xffffff, 3.5); |
| keyLight.position.set(3, 4, 4); |
| scene.add(keyLight); |
| const rimLight = new THREE.DirectionalLight(0xc7baff, 2.2); |
| rimLight.position.set(-4, 2, -3); |
| scene.add(rimLight); |
|
|
| const resize = () => { |
| const width = ui.stage.clientWidth; |
| const height = ui.stage.clientHeight; |
| renderer.setSize(width, height, false); |
| camera.aspect = width / Math.max(height, 1); |
| camera.updateProjectionMatrix(); |
| }; |
| const reset = () => { |
| camera.position.set(0, 0.15, 3.4); |
| controls.target.set(0, 0, 0); |
| controls.update(); |
| }; |
| window.addEventListener('resize', resize); |
| ui.reset.addEventListener('click', reset); |
| resize(); |
| const animate = () => { |
| requestAnimationFrame(animate); |
| controls.update(); |
| renderer.render(scene, camera); |
| }; |
| animate(); |
|
|
| return { |
| scene, |
| camera, |
| reset, |
| setMesh(meshData) { |
| if (currentMeshObject) { |
| scene.remove(currentMeshObject); |
| currentMeshObject.geometry.dispose(); |
| currentMeshObject.material.dispose(); |
| } |
| const geometry = new THREE.BufferGeometry(); |
| geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3)); |
| geometry.setIndex(new THREE.BufferAttribute(meshData.faces, 1)); |
| geometry.computeVertexNormals(); |
| geometry.computeBoundingBox(); |
| const center = geometry.boundingBox.getCenter(new THREE.Vector3()); |
| const size = geometry.boundingBox.getSize(new THREE.Vector3()); |
| const maxDimension = Math.max(size.x, size.y, size.z) || 1; |
| geometry.translate(-center.x, -center.y, -center.z); |
| geometry.scale(2.15 / maxDimension, 2.15 / maxDimension, 2.15 / maxDimension); |
| const material = new THREE.MeshStandardMaterial({ color: 0xc9b9ff, roughness: 0.44, metalness: 0.14, side: THREE.DoubleSide }); |
| currentMeshObject = new THREE.Mesh(geometry, material); |
| scene.add(currentMeshObject); |
| reset(); |
| ui.empty.classList.add('is-hidden'); |
| ui.stats.textContent = `${meshData.faces.length / 3} triangles · drag to orbit`; |
| ui.download.disabled = false; |
| }, |
| }; |
| } |
|
|
| const viewer = initViewer(); |
|
|
| async function runGeneration() { |
| if (!selectedFile || !webgpuReady) return; |
| const started = performance.now(); |
| ui.generate.disabled = true; |
| ui.download.disabled = true; |
| clearError(); |
| showProgress(true); |
| try { |
| setProgress('Preparing image', 'Composing the subject over SF3D’s neutral background.', 5); |
| const sourceCanvas = await prepareSourceCanvas(selectedFile); |
| preparedCanvas = prepareModelCanvas(sourceCanvas); |
| renderInputPreview(preparedCanvas, `${selectedFile.name} · prepared`, selectedFile.size); |
| setProgress('Loading models', 'Connecting to the browser-compatible SF3D export.', 15); |
| const [modelSessions, grid] = await Promise.all([createSessions(), loadGrid()]); |
| setProgress('Encoding image', 'The image tokenizer is running on WebGPU.', 78); |
| const tokenizerInputs = createInputTensors(preparedCanvas, modelSessions.imageTokenizer); |
| const tokenizerOutputs = await modelSessions.imageTokenizer.run(tokenizerInputs); |
| const imageTokens = resolveOutput(tokenizerOutputs, ['image_tokens']); |
| setProgress('Reconstructing shape', 'The SF3D backbone is predicting a 3D triplane.', 80); |
| const backboneInputName = resolveName(modelSessions.backbone.inputNames, ['image_tokens'], 0); |
| const backboneOutputs = await modelSessions.backbone.run({ [backboneInputName]: imageTokens }); |
| const triplane = resolveOutput(backboneOutputs, ['triplane']).data; |
| setProgress('Decoding surface', 'Sampling the triplane and extracting the tetrahedral surface.', 82); |
| const decoded = await decodeGrid(grid, triplane, modelSessions.decoder); |
| const meshData = buildMesh(grid, decoded); |
| currentMeshData = meshData; |
| viewer.setMesh(meshData); |
| const elapsed = performance.now() - started; |
| ui.runtime.textContent = formatDuration(elapsed); |
| ui.vertices.textContent = (meshData.positions.length / 3).toLocaleString(); |
| ui.faces.textContent = (meshData.faces.length / 3).toLocaleString(); |
| setProgress('Done', 'Your mesh is ready.', 100); |
| showProgress(false); |
| } catch (error) { |
| console.error(error); |
| showError('Generation failed', error?.message || 'The browser could not complete WebGPU inference.'); |
| ui.stats.textContent = 'No mesh loaded'; |
| } finally { |
| ui.generate.disabled = false; |
| ui.generateLabel.textContent = 'Generate 3D mesh'; |
| } |
| } |
|
|
| async function downloadGLB() { |
| if (!currentMeshObject) return; |
| ui.download.disabled = true; |
| try { |
| const exporter = new GLTFExporter(); |
| const result = await new Promise((resolve, reject) => exporter.parse(currentMeshObject, resolve, reject, { binary: true })); |
| const blob = new Blob([result], { type: 'model/gltf-binary' }); |
| const url = URL.createObjectURL(blob); |
| const anchor = document.createElement('a'); |
| anchor.href = url; |
| anchor.download = 'stable-fast-3d-mesh.glb'; |
| anchor.click(); |
| setTimeout(() => URL.revokeObjectURL(url), 1000); |
| } catch (error) { |
| showError('Download failed', error.message || 'The GLB exporter could not finish.'); |
| } finally { |
| ui.download.disabled = false; |
| } |
| } |
|
|
| async function checkWebGPU() { |
| if (!navigator.gpu) { |
| setWebGPUStatus('error', 'WebGPU unavailable'); |
| ui.generateLabel.textContent = 'WebGPU required'; |
| return; |
| } |
| try { |
| const adapter = await navigator.gpu.requestAdapter(); |
| if (!adapter) throw new Error('No compatible GPU adapter found.'); |
| webgpuReady = true; |
| setWebGPUStatus('ready', 'WebGPU ready'); |
| ui.generate.disabled = !selectedFile; |
| ui.generateLabel.textContent = selectedFile ? 'Generate 3D mesh' : 'Select an image'; |
| } catch (error) { |
| setWebGPUStatus('error', 'WebGPU unavailable'); |
| showError('WebGPU is required', error.message || 'Use a recent Chrome or Edge browser with WebGPU enabled.'); |
| } |
| } |
|
|
| ui.file.addEventListener('change', (event) => selectFile(event.target.files?.[0])); |
| ui.sample.addEventListener('click', loadSample); |
| ui.generate.addEventListener('click', runGeneration); |
| ui.download.addEventListener('click', downloadGLB); |
| ui.errorDismiss.addEventListener('click', clearError); |
| ui.dropzone.addEventListener('dragover', (event) => { event.preventDefault(); ui.dropzone.classList.add('dragging'); }); |
| ui.dropzone.addEventListener('dragleave', () => ui.dropzone.classList.remove('dragging')); |
| ui.dropzone.addEventListener('drop', (event) => { |
| event.preventDefault(); |
| ui.dropzone.classList.remove('dragging'); |
| selectFile(event.dataTransfer.files?.[0]); |
| }); |
| window.addEventListener('pagehide', () => { |
| if (backgroundPipeline?.dispose) backgroundPipeline.dispose(); |
| if (sessions) Object.values(sessions).forEach((session) => session.release?.()); |
| }); |
|
|
| checkWebGPU(); |
|
|