feat: Add UV reprojection and texture baking via WebGL
Build and Deploy / build-and-push (push) Successful in 1m27s
Build and Deploy / build-and-push (push) Successful in 1m27s
This commit is contained in:
@@ -7,6 +7,8 @@ import { useTexturePipeline } from "../hooks/useTexturePipeline";
|
|||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
object: THREE.Object3D | null;
|
object: THREE.Object3D | null;
|
||||||
sceneObjects: any[];
|
sceneObjects: any[];
|
||||||
|
bakeRequest?: { id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', timestamp: number } | null;
|
||||||
|
onBakeComplete?: (dataUrl: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeshMaterialManagerProps {
|
interface MeshMaterialManagerProps {
|
||||||
@@ -211,7 +213,9 @@ const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: T
|
|||||||
return createPortal(fillNode, originalMesh);
|
return createPortal(fillNode, originalMesh);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
|
import { reprojectUVs, bakeTextureToNewUVs } from '../lib/bakingUtils';
|
||||||
|
|
||||||
|
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects, bakeRequest, onBakeComplete }, ref) => {
|
||||||
const internalRef = useRef<THREE.Group>(null);
|
const internalRef = useRef<THREE.Group>(null);
|
||||||
const groupRef = (ref as any) || internalRef;
|
const groupRef = (ref as any) || internalRef;
|
||||||
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
|
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
|
||||||
@@ -241,6 +245,43 @@ export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object,
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { gl } = useThree();
|
||||||
|
|
||||||
|
// Handle Baking Request
|
||||||
|
useEffect(() => {
|
||||||
|
if (bakeRequest && onBakeComplete && meshCache.size > 0) {
|
||||||
|
const sceneObj = sceneObjects.find(obj => obj.id === bakeRequest.id);
|
||||||
|
if (!sceneObj) return;
|
||||||
|
|
||||||
|
// Find the primary mesh for this object (we assume the first mesh for now)
|
||||||
|
let targetMesh: THREE.Mesh | null = null;
|
||||||
|
for (const mesh of meshCache.values()) {
|
||||||
|
targetMesh = mesh;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetMesh && sceneObj.materialProps?.map) {
|
||||||
|
const tex = textureCache.current.get(sceneObj.materialProps.map);
|
||||||
|
if (tex) {
|
||||||
|
// Keep old UVs
|
||||||
|
const oldUVArray = targetMesh.geometry.attributes.uv.array.slice() as Float32Array;
|
||||||
|
|
||||||
|
// Apply new UV mapping (mutates geometry.attributes.uv)
|
||||||
|
reprojectUVs(targetMesh.geometry, bakeRequest.type);
|
||||||
|
|
||||||
|
// Bake
|
||||||
|
try {
|
||||||
|
const dataUrl = bakeTextureToNewUVs(targetMesh, oldUVArray, tex, gl, 1024);
|
||||||
|
onBakeComplete(dataUrl);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Baking failed", e);
|
||||||
|
onBakeComplete(sceneObj.materialProps.map); // Fallback to original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [bakeRequest, meshCache, sceneObjects, gl, onBakeComplete]);
|
||||||
|
|
||||||
// Main transform update for the core model
|
// Main transform update for the core model
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (object && sceneObjects) {
|
if (object && sceneObjects) {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates new UVs for a geometry based on the chosen projection mapping.
|
||||||
|
* Supported types: 'planar', 'cylinder', 'sphere', 'box'
|
||||||
|
*/
|
||||||
|
export function reprojectUVs(geometry: THREE.BufferGeometry, mappingType: 'planar' | 'cylinder' | 'sphere' | 'box') {
|
||||||
|
if (!geometry.attributes.position) return;
|
||||||
|
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const bbox = geometry.boundingBox!;
|
||||||
|
const size = new THREE.Vector3();
|
||||||
|
bbox.getSize(size);
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
bbox.getCenter(center);
|
||||||
|
|
||||||
|
const posAttr = geometry.attributes.position;
|
||||||
|
const uvArray = new Float32Array(posAttr.count * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < posAttr.count; i++) {
|
||||||
|
const x = posAttr.getX(i);
|
||||||
|
const y = posAttr.getY(i);
|
||||||
|
const z = posAttr.getZ(i);
|
||||||
|
|
||||||
|
// Normalize coordinates around center, scaled by size
|
||||||
|
const nx = size.x === 0 ? 0.5 : (x - bbox.min.x) / size.x;
|
||||||
|
const ny = size.y === 0 ? 0.5 : (y - bbox.min.y) / size.y;
|
||||||
|
const nz = size.z === 0 ? 0.5 : (z - bbox.min.z) / size.z;
|
||||||
|
|
||||||
|
let u = 0;
|
||||||
|
let v = 0;
|
||||||
|
|
||||||
|
if (mappingType === 'planar') {
|
||||||
|
// Front planar projection
|
||||||
|
u = nx;
|
||||||
|
v = ny;
|
||||||
|
} else if (mappingType === 'cylinder') {
|
||||||
|
// Cylindrical projection
|
||||||
|
const centeredX = x - center.x;
|
||||||
|
const centeredZ = z - center.z;
|
||||||
|
// atan2 returns -PI to PI. We want 0 to 1 around the cylinder.
|
||||||
|
let angle = Math.atan2(centeredZ, centeredX);
|
||||||
|
u = (angle + Math.PI) / (2 * Math.PI);
|
||||||
|
v = ny;
|
||||||
|
} else if (mappingType === 'sphere') {
|
||||||
|
// Spherical projection
|
||||||
|
const centeredX = x - center.x;
|
||||||
|
const centeredY = y - center.y;
|
||||||
|
const centeredZ = z - center.z;
|
||||||
|
const radius = Math.sqrt(centeredX * centeredX + centeredY * centeredY + centeredZ * centeredZ);
|
||||||
|
|
||||||
|
const phi = Math.acos(centeredY / radius); // 0 to PI
|
||||||
|
let theta = Math.atan2(centeredZ, centeredX); // -PI to PI
|
||||||
|
|
||||||
|
u = (theta + Math.PI) / (2 * Math.PI);
|
||||||
|
v = 1.0 - (phi / Math.PI);
|
||||||
|
} else if (mappingType === 'box') {
|
||||||
|
// Simple box projection (pick dominant axis based on normal)
|
||||||
|
// This requires normals
|
||||||
|
let normX = 0, normY = 0, normZ = 1;
|
||||||
|
if (geometry.attributes.normal) {
|
||||||
|
normX = geometry.attributes.normal.getX(i);
|
||||||
|
normY = geometry.attributes.normal.getY(i);
|
||||||
|
normZ = geometry.attributes.normal.getZ(i);
|
||||||
|
}
|
||||||
|
const ax = Math.abs(normX), ay = Math.abs(normY), az = Math.abs(normZ);
|
||||||
|
if (ax > ay && ax > az) {
|
||||||
|
u = nz; v = ny;
|
||||||
|
} else if (ay > ax && ay > az) {
|
||||||
|
u = nx; v = nz;
|
||||||
|
} else {
|
||||||
|
u = nx; v = ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp UVs to avoid edge artifacts
|
||||||
|
u = Math.max(0.001, Math.min(0.999, u));
|
||||||
|
v = Math.max(0.001, Math.min(0.999, v));
|
||||||
|
|
||||||
|
uvArray[i * 2] = u;
|
||||||
|
uvArray[i * 2 + 1] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2));
|
||||||
|
geometry.attributes.uv.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bakes the old texture applied to the old UVs onto a new texture using the new UVs.
|
||||||
|
* We do this by rendering the mesh to a WebGLRenderTarget using a custom shader.
|
||||||
|
* @param mesh The mesh with the new UVs applied to geometry.attributes.uv
|
||||||
|
* @param oldUVArray The Float32Array of the original UVs
|
||||||
|
* @param oldTexture The original texture map
|
||||||
|
* @param renderer The active WebGLRenderer
|
||||||
|
* @param resolution The resolution of the baked texture
|
||||||
|
* @returns A data URL of the baked texture
|
||||||
|
*/
|
||||||
|
export function bakeTextureToNewUVs(
|
||||||
|
mesh: THREE.Mesh,
|
||||||
|
oldUVArray: Float32Array,
|
||||||
|
oldTexture: THREE.Texture,
|
||||||
|
renderer: THREE.WebGLRenderer,
|
||||||
|
resolution = 1024
|
||||||
|
): string {
|
||||||
|
// Store original geometry attributes
|
||||||
|
const geometry = mesh.geometry.clone();
|
||||||
|
|
||||||
|
// Create a secondary UV set for the old UVs
|
||||||
|
geometry.setAttribute('uv2', new THREE.BufferAttribute(oldUVArray, 2));
|
||||||
|
|
||||||
|
// Custom Shader:
|
||||||
|
// Position is derived from the new UVs (to flatten the mesh into the 2D canvas)
|
||||||
|
// Color is sampled from the old texture using the old UVs (uv2)
|
||||||
|
const bakeMaterial = new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
tDiffuse: { value: oldTexture }
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
attribute vec2 uv2;
|
||||||
|
varying vec2 vOldUV;
|
||||||
|
void main() {
|
||||||
|
vOldUV = uv2;
|
||||||
|
// The new UVs (uv) range from 0 to 1.
|
||||||
|
// We map them to clip space: -1 to 1.
|
||||||
|
// Also flip Y so the texture is right-side up.
|
||||||
|
gl_Position = vec4(uv.x * 2.0 - 1.0, (uv.y * 2.0 - 1.0), 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform sampler2D tDiffuse;
|
||||||
|
varying vec2 vOldUV;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = texture2D(tDiffuse, vOldUV);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
|
||||||
|
const bakeMesh = new THREE.Mesh(geometry, bakeMaterial);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.add(bakeMesh);
|
||||||
|
|
||||||
|
// Orthographic camera covering exactly -1 to 1 clip space
|
||||||
|
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1);
|
||||||
|
|
||||||
|
// Render target for the bake
|
||||||
|
const renderTarget = new THREE.WebGLRenderTarget(resolution, resolution, {
|
||||||
|
format: THREE.RGBAFormat,
|
||||||
|
type: THREE.UnsignedByteType,
|
||||||
|
colorSpace: THREE.SRGBColorSpace,
|
||||||
|
magFilter: THREE.LinearFilter,
|
||||||
|
minFilter: THREE.LinearFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalRenderTarget = renderer.getRenderTarget();
|
||||||
|
const originalClearColor = renderer.getClearColor(new THREE.Color());
|
||||||
|
const originalClearAlpha = renderer.getClearAlpha();
|
||||||
|
|
||||||
|
// Render
|
||||||
|
renderer.setRenderTarget(renderTarget);
|
||||||
|
renderer.setClearColor(0x000000, 0); // Transparent background
|
||||||
|
renderer.clear();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
|
||||||
|
// Read pixels from render target
|
||||||
|
const pixels = new Uint8Array(resolution * resolution * 4);
|
||||||
|
renderer.readRenderTargetPixels(renderTarget, 0, 0, resolution, resolution, pixels);
|
||||||
|
|
||||||
|
// Restore renderer state
|
||||||
|
renderer.setRenderTarget(originalRenderTarget);
|
||||||
|
renderer.setClearColor(originalClearColor, originalClearAlpha);
|
||||||
|
|
||||||
|
// Flip Y when copying to canvas because readRenderTargetPixels reads bottom-up
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = resolution;
|
||||||
|
canvas.height = resolution;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error("Could not get 2d context");
|
||||||
|
|
||||||
|
const imgData = ctx.createImageData(resolution, resolution);
|
||||||
|
// Manual flip Y
|
||||||
|
for (let y = 0; y < resolution; y++) {
|
||||||
|
for (let x = 0; x < resolution; x++) {
|
||||||
|
const srcIndex = ((resolution - 1 - y) * resolution + x) * 4;
|
||||||
|
const destIndex = (y * resolution + x) * 4;
|
||||||
|
imgData.data[destIndex] = pixels[srcIndex];
|
||||||
|
imgData.data[destIndex + 1] = pixels[srcIndex + 1];
|
||||||
|
imgData.data[destIndex + 2] = pixels[srcIndex + 2];
|
||||||
|
imgData.data[destIndex + 3] = pixels[srcIndex + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
renderTarget.dispose();
|
||||||
|
geometry.dispose();
|
||||||
|
bakeMaterial.dispose();
|
||||||
|
|
||||||
|
// Return base64 JPEG to match sizes
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
}
|
||||||
+33
-1
@@ -638,6 +638,11 @@ export default function Editor() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [exportDialogName, setExportDialogName] = useState('');
|
const [exportDialogName, setExportDialogName] = useState('');
|
||||||
const [isRendering, setIsRendering] = useState(false);
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
|
const [rendering, setRendering] = useState(false);
|
||||||
|
const [renderProgress, setRenderProgress] = useState<{ step: string; progress: number } | null>(null);
|
||||||
|
const [isRenderComplete, setIsRenderComplete] = useState(false);
|
||||||
|
const [bakedDataUrl, setBakedDataUrl] = useState<string | null>(null);
|
||||||
|
const [bakeRequest, setBakeRequest] = useState<{ id: string, type: 'planar' | 'cylinder' | 'sphere' | 'box', timestamp: number } | null>(null);
|
||||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null);
|
const [modelData, setModelData] = useState<{ name: string, data: string, extension: string } | null>(null);
|
||||||
@@ -2528,7 +2533,18 @@ export default function Editor() {
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<ModelViewer ref={modelRef} object={loadedModel} sceneObjects={sceneObjects} />
|
<ModelViewer
|
||||||
|
ref={modelRef}
|
||||||
|
object={loadedModel}
|
||||||
|
sceneObjects={sceneObjects}
|
||||||
|
bakeRequest={bakeRequest}
|
||||||
|
onBakeComplete={(dataUrl) => {
|
||||||
|
if (bakeRequest) {
|
||||||
|
updateObjectMaterial(bakeRequest.id, { map: dataUrl, mappingType: 'uv' });
|
||||||
|
}
|
||||||
|
setBakeRequest(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{(!isRendering || !renderRequest?.includeAlpha) ? (
|
{(!isRendering || !renderRequest?.includeAlpha) ? (
|
||||||
customHdri ? (
|
customHdri ? (
|
||||||
@@ -4667,8 +4683,24 @@ export default function Editor() {
|
|||||||
<option value="planar">Planar</option>
|
<option value="planar">Planar</option>
|
||||||
<option value="box">Box</option>
|
<option value="box">Box</option>
|
||||||
<option value="cylinder">Cylinder</option>
|
<option value="cylinder">Cylinder</option>
|
||||||
|
<option value="sphere">Sphere</option>
|
||||||
<option value="triplanar">Triplanar</option>
|
<option value="triplanar">Triplanar</option>
|
||||||
</select>
|
</select>
|
||||||
|
{selectedObject.materialProps?.mappingType && selectedObject.materialProps.mappingType !== 'uv' && selectedObject.materialProps.mappingType !== 'triplanar' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedObject.materialProps?.map) return;
|
||||||
|
setBakeRequest({
|
||||||
|
id: selectedObject.id,
|
||||||
|
type: selectedObject.materialProps.mappingType as any,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full py-2 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded shadow transition-colors mt-1 text-[11px]"
|
||||||
|
>
|
||||||
|
{bakeRequest?.id === selectedObject.id ? 'Baking...' : 'Bake to UVs (Required for 2D AI)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* UV Offset */}
|
{/* UV Offset */}
|
||||||
|
|||||||
Reference in New Issue
Block a user