Files
yourpart3/frontend/src/utils/characterAppearance.js
Torsten Schulz (local) b805632826
All checks were successful
Deploy to production / deploy (push) Successful in 2m36s
Ergänze 3D-Vorschau zur individuellen Haar- und Augenfarbe
2026-11-04 15:45:02 +01:00

218 lines
8.9 KiB
JavaScript

import { Color, Vector3 } from 'three';
export const APPEARANCE_REGIONS = Object.freeze(['body', 'hair', 'iris']);
const editorStates = new WeakMap();
const colorStates = new WeakMap();
export function getAppearanceRegion(material) {
const region = material?.userData?.falukantAppearance?.region;
if (APPEARANCE_REGIONS.includes(region)) return region;
const name = String(material?.name || '').toLowerCase();
if (['hair', 'falukant_hair'].includes(name)) return 'hair';
if (['iris', 'falukant_iris'].includes(name)) return 'iris';
return 'body';
}
function setupColor(material) {
if (colorStates.has(material)) return colorStates.get(material);
const uniforms = { target: { value: new Color() }, enabled: { value: 0 } };
const previousCompile = material.onBeforeCompile;
material.onBeforeCompile = function (shader, renderer) {
previousCompile.call(this, shader, renderer);
shader.uniforms.falukantTargetColor = uniforms.target;
shader.uniforms.falukantColorEnabled = uniforms.enabled;
shader.fragmentShader = `uniform vec3 falukantTargetColor;
uniform float falukantColorEnabled;\n${shader.fragmentShader}`;
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', `
#include <map_fragment>
float falukantDetail = clamp(dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)) * 1.8 + 0.18, 0.18, 1.0);
diffuseColor.rgb = mix(diffuseColor.rgb, falukantTargetColor * falukantDetail, falukantColorEnabled);
`);
// Some Meshy models also bake the original color into an emissive map.
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#include <emissivemap_fragment>
float falukantEmission = dot(totalEmissiveRadiance, vec3(0.2126, 0.7152, 0.0722));
totalEmissiveRadiance = mix(totalEmissiveRadiance, falukantTargetColor * falukantEmission, falukantColorEnabled);
`);
};
material.customProgramCacheKey = () => 'falukant-appearance-v1';
material.needsUpdate = true;
colorStates.set(material, uniforms);
return uniforms;
}
export function applyCharacterAppearance(model, appearance = {}) {
model.traverse(object => {
if (!object.isMesh) return;
for (const material of [].concat(object.material || [])) {
const region = getAppearanceRegion(material);
if (region === 'body') continue;
const color = region === 'hair' ? appearance.hairColor : appearance.eyeColor;
const state = setupColor(material);
const valid = typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color);
state.enabled.value = valid ? 1 : 0;
if (valid) state.target.value.set(color);
}
});
}
// Keep texture references, UVs, normals and skinning attributes. Only split
// triangle indices into standard glTF material groups. No texture guessing.
export function prepareAppearanceEditor(model) {
model.traverse(mesh => {
if (!mesh.isMesh || editorStates.has(mesh)) return;
const original = mesh.geometry;
if (!original.getAttribute('position')) return;
mesh.geometry = original.clone();
const geometry = mesh.geometry;
if (!geometry.index) {
geometry.setIndex(Array.from({ length: geometry.getAttribute('position').count }, (_, i) => i));
}
const oldMaterials = [].concat(mesh.material);
const sources = new Map();
const materialSources = [];
const materials = [];
oldMaterials.forEach((material, oldIndex) => {
const key = material.userData?.falukantAppearance?.source ?? `material-${oldIndex}`;
if (!sources.has(key)) {
sources.set(key, sources.size);
for (const region of APPEARANCE_REGIONS) {
const clone = material.clone();
clone.name = `Falukant_${region}_${sources.get(key)}`;
clone.userData.falukantAppearance = { version: 1, region, source: key };
materials.push(clone);
}
}
materialSources[oldIndex] = sources.get(key);
});
const count = geometry.index.count / 3;
const regions = new Uint8Array(count);
const bases = new Uint16Array(count);
const groups = geometry.groups.length ? geometry.groups : [{ start: 0, count: geometry.index.count, materialIndex: 0 }];
for (const group of groups) {
const materialIndex = group.materialIndex || 0;
const region = APPEARANCE_REGIONS.indexOf(getAppearanceRegion(oldMaterials[materialIndex]));
for (let face = group.start / 3; face < (group.start + group.count) / 3; face++) {
regions[face] = region;
bases[face] = materialSources[materialIndex];
}
}
mesh.material = materials;
const state = { regions, bases };
editorStates.set(mesh, state);
rebuildGroups(mesh, state);
});
model.userData.falukantAppearance = { version: 1, regions: APPEARANCE_REGIONS.slice() };
}
function rebuildGroups(mesh, state) {
const geometry = mesh.geometry;
const oldIndex = geometry.index.array;
const buckets = Array.from({ length: mesh.material.length }, () => []);
for (let face = 0; face < state.regions.length; face++) {
buckets[state.bases[face] * 3 + state.regions[face]].push(face);
}
const index = new oldIndex.constructor(oldIndex.length);
const regions = new Uint8Array(state.regions.length);
const bases = new Uint16Array(state.bases.length);
let offset = 0;
geometry.clearGroups();
buckets.forEach((faces, materialIndex) => {
if (!faces.length) return;
geometry.addGroup(offset * 3, faces.length * 3, materialIndex);
for (const face of faces) {
index.set(oldIndex.subarray(face * 3, face * 3 + 3), offset * 3);
regions[offset] = state.regions[face];
bases[offset] = state.bases[face];
offset++;
}
});
state.regions = regions;
state.bases = bases;
// Reuse the GPU index buffer while painting instead of leaking a new one
// for each brush movement.
geometry.index.array.set(index);
geometry.index.needsUpdate = true;
}
export function paintAppearanceRegion(hit, worldRadius, region, cameraDirection) {
const mesh = hit.object;
const state = editorStates.get(mesh);
const label = APPEARANCE_REGIONS.indexOf(region);
if (!state || label < 0 || !Number.isInteger(hit.faceIndex)) return false;
const point = mesh.worldToLocal(hit.point.clone());
const scale = mesh.getWorldScale(new Vector3());
const radius = worldRadius / Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z));
const direction = cameraDirection.clone().transformDirection(mesh.matrixWorld.clone().invert());
const positions = mesh.geometry.getAttribute('position');
const index = mesh.geometry.index.array;
const a = new Vector3(), b = new Vector3(), c = new Vector3();
const normal = new Vector3(), edge = new Vector3(), center = new Vector3();
let changed = false;
for (let face = 0; face < state.regions.length; face++) {
if (state.regions[face] === label) continue;
a.fromBufferAttribute(positions, index[face * 3]);
b.fromBufferAttribute(positions, index[face * 3 + 1]);
c.fromBufferAttribute(positions, index[face * 3 + 2]);
center.copy(a).add(b).add(c).multiplyScalar(1 / 3);
normal.subVectors(b, a).cross(edge.subVectors(c, a));
if (face === hit.faceIndex || (center.distanceToSquared(point) <= radius * radius && normal.dot(direction) < 0)) {
state.regions[face] = label;
changed = true;
}
}
if (changed) rebuildGroups(mesh, state);
return changed;
}
export function captureAppearanceEditor(model) {
const snapshot = [];
model.traverse(mesh => {
const state = editorStates.get(mesh);
if (state) snapshot.push({ mesh, index: mesh.geometry.index.clone(), regions: state.regions.slice(), bases: state.bases.slice() });
});
return snapshot;
}
export function restoreAppearanceEditor(snapshot) {
for (const { mesh, index, regions, bases } of snapshot) {
mesh.geometry.index.array.set(index.array);
mesh.geometry.index.needsUpdate = true;
const state = { regions: regions.slice(), bases: bases.slice() };
editorStates.set(mesh, state);
rebuildGroups(mesh, state);
}
}
export function getAppearanceStats(model) {
const stats = { hair: 0, iris: 0, body: 0 };
model.traverse(mesh => {
if (!mesh.isMesh) return;
const materials = [].concat(mesh.material);
for (const group of mesh.geometry.groups) {
stats[getAppearanceRegion(materials[group.materialIndex || 0])] += group.count / 3;
}
});
return stats;
}
export function disposeCharacterModel(model) {
const geometries = new Set(), materials = new Set(), textures = new Set(), images = new Set();
model.traverse(object => {
if (object.geometry) geometries.add(object.geometry);
for (const material of [].concat(object.material || [])) materials.add(material);
});
for (const material of materials) {
for (const value of Object.values(material)) {
if (value?.isTexture) {
textures.add(value);
if (value.source?.data) images.add(value.source.data);
}
}
material.dispose();
}
geometries.forEach(geometry => geometry.dispose());
textures.forEach(texture => texture.dispose());
images.forEach(image => image.close?.());
}