Ersetze grobe 3D-Markierungen durch präzise UV-Farbmasken
All checks were successful
Deploy to production / deploy (push) Successful in 2m33s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 16:16:01 +01:00
parent b805632826
commit dd409a6a6a
5 changed files with 477 additions and 249 deletions

View File

@@ -4,42 +4,45 @@
Die vorhandenen Meshy-GLBs enthalten ein gemeinsames Mesh und Material für Haut,
Kleidung, Haare und Augen. Eine Änderung der allgemeinen Materialfarbe würde
deshalb die gesamte Figur färben. Die Bereiche müssen einmal pro Modell
zugeordnet werden; die Geometrie wird dabei nicht anhand von Farbähnlichkeiten
automatisch geraten.
alle Bereiche färben. Die Zuordnung erfolgt deshalb über eine eigene UV-Maske:
Rot markiert Haare, Grün die Iris, Schwarz unveränderte Bereiche.
Unter `/falukant/dev-all-characters` öffnet sich jetzt standardmäßig die
Vorschau **Farben und Modellbereiche**. Die bisherige Altersübersicht bleibt
über **Alle Altersmodelle** erreichbar.
Die erste Version verwendete ganze Dreiecke als Markierung. Gerade an Augen
waren diese zu grob. Version 2 bemalt stattdessen einzelne Texturpixel. Die
Dreiecke, ihre Indizes und die ursprünglichen UV-Koordinaten bleiben unverändert.
Unter `/falukant/dev-all-characters` öffnet sich standardmäßig die Vorschau
**Farben und Modellbereiche**. Die Altersübersicht bleibt über **Alle Altersmodelle**
erreichbar.
1. Geschlecht/Alter wählen oder eine lokale GLB-Datei öffnen. Für die Bearbeitung
möglichst die Quelldatei aus `YourPart3Assets/models-src/3d/falukant/characters`
statt eines bereits reduzierten Laufzeitmodells verwenden.
2. In **Drehen / Zoom** das Modell ausrichten. Das Mausrad zoomt, die rechte
Maustaste verschiebt die Ansicht.
3. **Haare markieren** wählen und sichtbare Haarflächen überstreichen. Zum
Erreichen weiterer Seiten wieder in den Drehmodus wechseln.
4. Für **Iris markieren** nahe heranzoomen und einen kleinen Pinsel verwenden.
Augenweiß, Pupillen, Lider und Wimpern aussparen.
5. Mit **Markierung entfernen** Bereiche auf ihre ursprüngliche Textur
zurücksetzen. Die letzten zehn Pinselstriche lassen sich rückgängig machen.
6. Augen-/Haarfarbe ändern und die Zuordnung prüfen. Die Checkbox zeigt zum
Vergleich die Originalfarben. Haut und Kleidung werden nicht eingefärbt.
7. Das bearbeitete GLB herunterladen und erneut öffnen, um die Zuordnung zu
kontrollieren. Exportiert werden die Materialbereiche mit ihren ursprünglichen
Texturen, nicht die gewählten Vorschaufarben.
2. In **Drehen / Zoom** ausrichten. Das Mausrad zoomt bis zur Nahansicht; die
rechte Maustaste verschiebt die Ansicht. Die Editor-Kamera erlaubt auch das
genaue Betrachten der Iris, ohne dass die Nah-Clipping-Ebene sie abschneidet.
3. **Haare markieren** wählen und über die sichtbaren Haarflächen streichen.
Zum Erreichen anderer Seiten wieder in den Drehmodus wechseln.
4. Für **Iris markieren** einen kleinen Pinsel verwenden. Der sichtbare Kreis
zeigt die Pinselgröße. Pupille, Augenweiß, Lider und Wimpern aussparen.
5. **Markierung entfernen** radiert lokal. **Alle Markierungen löschen** entfernt
die gesamte Zuordnung, etwa zum Ersetzen alter dreieckiger Markierungen.
Die letzten zehn Pinselstriche bzw. Löschaktionen sind rückgängig machbar.
6. Farben ausprobieren. Die Checkbox zeigt zum Vergleich die Originalfarben.
Außerhalb der Maske bleiben Haut und Kleidung unverändert.
7. Das bearbeitete GLB herunterladen und erneut öffnen. Gespeichert wird die
Maske mit den ursprünglichen Texturen, nicht die gewählte Vorschaufarbe.
Die Originaldateien im Asset-Repository werden vom Editor nicht überschrieben.
Nach Kontrolle die heruntergeladene Datei unter dem bisherigen Quellnamen im
Asset-Repository ablegen, z. B. `female_20y.glb`, und das Asset-Repository committen.
Die bestehende Pipeline `npm run build-runtime-models -- --assets-dir
../YourPart3Assets --category characters` erstellt daraus die optimierten GLBs.
Die Exportdatei erhält bewusst keinen `_opt`-Suffix, da solche Dateien vom
Quellmodell-Build übersprungen werden.
Originaldateien im Asset-Repository werden nicht automatisch überschrieben.
Nach Kontrolle die Datei unter dem bisherigen Quellnamen dort ablegen, z. B.
`female_20y.glb`, und das Asset-Repository committen. Die bestehende Pipeline
`npm run build-runtime-models -- --assets-dir ../YourPart3Assets --category characters`
erstellt daraus die optimierten GLBs. Die Exportdatei erhält keinen `_opt`-Suffix,
da solche Dateien vom Quellmodell-Build übersprungen werden.
Noch nicht markierte Modelle behalten ihr bisheriges Aussehen. Diese Änderung
ersetzt nicht automatisch alle bestehenden GLBs. Es gibt keine neue
Datenbankmigration oder Speicherung pro Spielcharakter.
Unmarkierte Modelle behalten ihr bisheriges Aussehen. Die vollständige
Markierung aller Modellvarianten ist eine einmalige Aufgabe je Modell. Es gibt
keine neue Datenbankmigration oder Speicherung pro Spielcharakter.
## Schnittstelle für die Anzeige
@@ -53,42 +56,55 @@ Datenbankmigration oder Speicherung pro Spielcharakter.
/>
```
Nur sechsstellige Hexfarben werden angewendet. `null` oder ein fehlender Wert
zeigt für den jeweiligen Bereich die Originaltextur. Farbänderungen benötigen
keinen erneuten Download und keine neue Modellinstanz.
Nur sechsstellige Hexfarben werden angewendet. Fehlende Werte oder `null`
zeigen die Originaltextur. Farbänderungen benötigen keinen neuen Modell-Download.
Materialien enthalten glTF-`extras` in dieser Form:
```json
{
"falukantAppearance": {
"version": 1,
"region": "hair",
"source": "material-0"
"version": 2,
"region": "masked",
"uvSet": 0,
"maskPng": "data:image/png;base64,..."
}
}
```
Bereiche sind `body`, `hair` und `iris`. `source` ordnet die Bereiche dem
ursprünglichen Material zu. Alternativ werden für extern vorbereitete Modelle
die exakten Materialnamen `hair`, `iris`, `falukant_hair` und `falukant_iris`
erkannt. Die Standardmaterialien und Texturkoordinaten bleiben erhalten.
Der Pinsel teilt Dreiecke in Materialgruppen; die Genauigkeit hängt deshalb
von der vorhandenen Geometrie ab. Sehr grobe Augenbereiche benötigen eine
feinere Geometrie oder eine spätere Texturmaskierung.
Die Maske liegt als PNG direkt im GLB. Neue Masken verwenden passend zur
Originaltextur 2048×2048 bis 4096×4096 Pixel. Sie wird
nicht in die Farbtextur eingebrannt und benötigt keinen eigenen Server-Endpunkt.
Die bestehende Draco-/Textur-Optimierung erhält die Material-Extras und verändert
die Maske nicht. Beim Laden wird sie als lineare Datentextur ohne Y-Spiegelung
interpretiert, passend zu den glTF-UVs. Für die normale Spielanzeige wird sie
im Speicher auf höchstens 2048 Pixel Kantenlänge reduziert; im Editor bleibt
die gespeicherte Auflösung erhalten. Haar- und Irisfarbe werden darüber im
Shader gemischt; auch eingebrannte Farben emissiver Texturen werden berücksichtigt.
Die Farbanpassung erhält Helligkeitsdetails und berücksichtigt auch eingebrannte
Farben in emissiven Texturen. Shader-Uniforms werden pro Material gehalten und
nicht als Laufzeitobjekte in das GLB geschrieben. Der Export stellt die originale
Wurzeltransformation wieder her und übernimmt vorhandene Animationen.
Der Pinsel rastert UV-Dreiecke anhand ihrer baryzentrischen Oberflächenpositionen.
Damit bleibt seine Größe in der Modelloberfläche konsistent und er kann über
UV-Nähte hinweg malen. Er färbt keine ganzen Dreiecke als Ersatz für kleine
Pinselstriche. Die Genauigkeit ist durch die Maskenauflösung begrenzt; mehrfach
verwendete UV-Bereiche teilen sich auch die Maske.
Rückgängig speichert nur veränderte 64×64-Pixel-Kacheln pro Pinselstrich statt
zehn vollständiger Masken. Im Export landen ausschließlich PNG-Daten und
Versionsinformationen, keine Shader- oder Undo-Objekte. Die originale
Wurzeltransformation und vorhandene Animationen werden übernommen.
Version-1-Materialzuordnungen und die exakten Materialnamen `hair`, `iris`,
`falukant_hair`, `falukant_iris` werden weiterhin geladen. Bestehende grobe
Markierungen werden nicht automatisch als Iris erkannt oder korrigiert. Sie
lassen sich lokal radieren oder vollständig löschen und pixelgenau neu anlegen.
Beim nächsten Export wird das Ergebnis als Version 2 gespeichert.
## Spätere altersabhängige Attribute
Ergrauen kann später über eine altersabhängige Haarfarbe auf derselben
Schnittstelle aufsetzen. Für Wachstum oder Veränderungen der Gesichtsform
braucht es gezielt vorbereitete Morph-Targets oder austauschbare Geometrie.
Die bisherigen voneinander unabhängigen Altersmodelle werden durch die
Farbzuordnung nicht automatisch zu einem verformbaren Basismodell.
Ergrauen kann über eine altersabhängige Haarfarbe aufsetzen. Für Wachstum oder
Veränderungen der Gesichtsform braucht es Morph-Targets oder austauschbare
Geometrie. Die bisherigen unabhängigen Altersmodelle werden durch die Maskierung
nicht automatisch zu einem verformbaren Basismodell.
## Prüfung
@@ -98,7 +114,8 @@ npm run test:character-appearance
npm run build
```
Zusätzlich wurde ein vorhandenes `female_20y.glb` im Browser geladen, mit Haar-
und Irisbereichen versehen, exportiert, mit der bestehenden Draco-/Simplify-
Pipeline optimiert und erneut importiert. Beide Bereiche und ihre glTF-Metadaten
blieben erhalten. Die vollständige Markierung aller Modellvarianten steht noch aus.
Regressionstests prüfen kleine Iris-Pinsel auf großen Dreiecken, unveränderte
Geometrie, UV-Nähte, unabhängige Farben/Instanzen, lokales Radieren, Rückgängig
und Version-1-Kompatibilität. Im Browser wird zusätzlich das vorhandene
`female_20y.glb` in starker Vergrößerung geprüft, mit Maske exportiert,
Draco-optimiert und erneut importiert.

View File

@@ -1,76 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import { BufferAttribute, BufferGeometry, Group, Mesh, MeshStandardMaterial, PlaneGeometry, Vector2, Vector3 } from 'three';
import {
applyCharacterAppearance, captureAppearanceEditor, getAppearanceRegion,
applyCharacterAppearance, captureAppearanceEditor, clearAppearanceMarks, finishAppearanceStroke,
getAppearanceStats, paintAppearanceRegion, prepareAppearanceEditor, restoreAppearanceEditor,
} from '../src/utils/characterAppearance.js';
function fixture(material = new MeshStandardMaterial()) {
function fixture(material = new MeshStandardMaterial(), geometry = new PlaneGeometry(2, 2)) {
const model = new Group();
const mesh = new Mesh(new BoxGeometry(), material);
const mesh = new Mesh(geometry, material);
model.add(mesh);
model.updateMatrixWorld(true);
return { model, mesh };
}
function shaderFor(material) {
const shader = { uniforms: {}, vertexShader: '#include <uv_vertex>', fragmentShader: '#include <map_fragment>\n#include <emissivemap_fragment>' };
material.onBeforeCompile(shader);
return shader;
}
function sampleMask(material, u, v) {
const { data, width, height } = shaderFor(material).uniforms.falukantRegionMask.value.image;
const index = (Math.floor(v * height) * width + Math.floor(u * width)) * 4;
return Array.from(data.subarray(index, index + 2));
}
function paint(mesh, point, radius = .16, region = 'iris') {
return paintAppearanceRegion({ object: mesh, faceIndex: 0, point, uv: new Vector2(point.x / 2 + .5, point.y / 2 + .5) }, radius, region, new Vector3(0, 0, -1));
}
test('painting separates material regions while preserving the original mesh and UVs', () => {
test('a small iris brush paints pixels within a large triangle without changing any geometry', async () => {
const { model, mesh } = fixture();
const original = mesh.geometry;
const originalIndex = original.index.array.slice();
const originalUV = original.getAttribute('uv').array.slice();
prepareAppearanceEditor(model);
assert.notEqual(mesh.geometry, original);
assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
await prepareAppearanceEditor(model, { maskSize: 128 });
const snapshot = captureAppearanceEditor(model);
const indexBuffer = mesh.geometry.index;
assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3(.5, 0, 0) }, 0, 'hair', new Vector3(-1, 0, 0)), true);
assert.deepEqual(getAppearanceStats(model), { body: 11, hair: 1, iris: 0 });
assert.equal(paint(mesh, new Vector3(.35, .1, 0)), true);
const stats = getAppearanceStats(model);
assert.ok(stats.iris > 10 && stats.iris < 500, 'The brush must not color the whole triangle');
assert.ok(sampleMask(mesh.material, .675, .55)[1] > 200);
assert.deepEqual(sampleMask(mesh.material, .85, .75), [0, 0], 'Skin outside the brush stays unmarked');
assert.equal(mesh.geometry, original);
assert.deepEqual(mesh.geometry.index.array, originalIndex);
assert.deepEqual(mesh.geometry.getAttribute('uv').array, originalUV);
assert.equal(mesh.geometry.index, indexBuffer);
assert.equal(original.groups.length, 6);
finishAppearanceStroke(model);
restoreAppearanceEditor(snapshot);
assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3() }, 1, 'unknown', new Vector3()), false);
assert.deepEqual(getAppearanceStats(model), { body: 128 * 128, hair: 0, iris: 0 });
});
test('hair and iris colors remain independent of other instances and can return to the original', () => {
test('hair and iris colors are independent across instances and can be disabled', async () => {
const originalMaterial = new MeshStandardMaterial();
const first = fixture(originalMaterial);
const second = fixture(originalMaterial);
prepareAppearanceEditor(first.model);
prepareAppearanceEditor(second.model);
const first = fixture(originalMaterial), second = fixture(originalMaterial);
await prepareAppearanceEditor(first.model, { maskSize: 32 });
await prepareAppearanceEditor(second.model, { maskSize: 32 });
applyCharacterAppearance(first.model, { hairColor: '#ff0000', eyeColor: '#0000ff' });
applyCharacterAppearance(second.model, { hairColor: '#00ff00' });
const shader = () => ({ uniforms: {}, fragmentShader: '#include <map_fragment>\n#include <emissivemap_fragment>' });
const hairShader = shader(), irisShader = shader(), otherShader = shader();
first.mesh.material[1].onBeforeCompile(hairShader);
first.mesh.material[2].onBeforeCompile(irisShader);
second.mesh.material[1].onBeforeCompile(otherShader);
assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'ff0000');
assert.equal(irisShader.uniforms.falukantTargetColor.value.getHexString(), '0000ff');
assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00');
const firstShader = shaderFor(first.mesh.material), secondShader = shaderFor(second.mesh.material);
assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'ff0000');
assert.equal(firstShader.uniforms.falukantIrisColor.value.getHexString(), '0000ff');
assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00');
assert.notEqual(firstShader.uniforms.falukantRegionMask.value, secondShader.uniforms.falukantRegionMask.value);
paint(first.mesh, new Vector3(), .2, 'hair');
assert.equal(getAppearanceStats(second.model).hair, 0);
applyCharacterAppearance(first.model, { hairColor: '#cccccc', eyeColor: null });
assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'cccccc');
assert.equal(irisShader.uniforms.falukantColorEnabled.value, 0);
assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00');
applyCharacterAppearance(first.model, { hairColor: 'invalid' });
assert.equal(hairShader.uniforms.falukantColorEnabled.value, 0);
assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'cccccc');
assert.equal(firstShader.uniforms.falukantIrisEnabled.value, 0);
assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00');
});
test('saved glTF material metadata can be prepared again without multiplying materials', () => {
const first = fixture();
prepareAppearanceEditor(first.model);
const model = new Group();
const mesh = new Mesh(first.mesh.geometry.clone(), first.mesh.material.map(material => {
const copy = material.clone();
copy.userData = JSON.parse(JSON.stringify(material.userData));
return copy;
}));
model.add(mesh);
prepareAppearanceEditor(model);
assert.equal(mesh.material.length, 3);
assert.equal(getAppearanceRegion(mesh.material[1]), 'hair');
assert.equal(getAppearanceRegion(mesh.material[2]), 'iris');
prepareAppearanceEditor(model);
assert.equal(mesh.material.length, 3);
test('an iris brush spans separate UV islands along a shared surface edge', async () => {
const geometry = new BufferGeometry();
geometry.setAttribute('position', new BufferAttribute(new Float32Array([
-1, -1, 0, 1, -1, 0, -1, 1, 0,
1, -1, 0, 1, 1, 0, -1, 1, 0,
]), 3));
geometry.setAttribute('uv', new BufferAttribute(new Float32Array([
0, 0, .4, 0, 0, 1,
.6, 0, 1, 1, .6, 1,
]), 2));
const { model, mesh } = fixture(undefined, geometry);
await prepareAppearanceEditor(model, { maskSize: 128 });
assert.equal(paint(mesh, new Vector3(), .3), true);
const { data, width } = shaderFor(mesh.material).uniforms.falukantRegionMask.value.image;
const columns = [];
for (let i = 0; i < data.length; i += 4) if (data[i + 1]) columns.push((i / 4) % width);
assert.ok(columns.some(x => x < width * .4));
assert.ok(columns.some(x => x > width * .6));
});
test('erasing is pixel-precise, undo restores it and legacy material labels remain editable', async () => {
const material = new MeshStandardMaterial();
material.userData.falukantAppearance = { version: 1, region: 'iris', source: 'material-0' };
const { model, mesh } = fixture(material);
await prepareAppearanceEditor(model, { maskSize: 64 });
assert.equal(getAppearanceStats(model).iris, 64 * 64);
const before = captureAppearanceEditor(model);
assert.equal(paint(mesh, new Vector3(), .2, 'body'), true);
finishAppearanceStroke(model);
assert.deepEqual(sampleMask(mesh.material, .5, .5), [0, 0]);
assert.equal(sampleMask(mesh.material, .9, .9)[1], 255);
restoreAppearanceEditor(before);
assert.equal(getAppearanceStats(model).iris, 64 * 64);
const clearSnapshot = captureAppearanceEditor(model);
clearAppearanceMarks(model);
finishAppearanceStroke(model);
assert.equal(getAppearanceStats(model).iris, 0);
restoreAppearanceEditor(clearSnapshot);
assert.equal(getAppearanceStats(model).iris, 64 * 64);
await prepareAppearanceEditor(model);
assert.equal(getAppearanceStats(model).iris, 64 * 64);
});

View File

@@ -2,7 +2,8 @@
<div class="character-3d-shell">
<div v-show="!showFallback" ref="container" class="character-3d-container"
@pointerdown.capture="startPainting" @pointermove="continuePainting"
@pointerup="finishPainting" @pointercancel="finishPainting"></div>
@pointerup="finishPainting" @pointercancel="finishPainting" @pointerleave="brushCursor.visible = false"></div>
<div v-if="brushCursor.visible && paintRegion" class="appearance-brush-cursor" :style="brushCursorStyle"></div>
<img
v-if="showFallback"
class="character-fallback"
@@ -144,6 +145,7 @@ export default {
undoHistory: [],
painting: false,
lastPaintAt: 0,
brushCursor: { visible: false, x: 0, y: 0, diameter: 0 },
animationId: null,
mixer: null,
clock: null,
@@ -169,6 +171,12 @@ export default {
};
},
computed: {
brushCursorStyle() {
return {
left: `${this.brushCursor.x}px`, top: `${this.brushCursor.y}px`,
width: `${this.brushCursor.diameter}px`, height: `${this.brushCursor.diameter}px`,
};
},
isDev() {
return !import.meta.env.PROD;
},
@@ -226,6 +234,7 @@ export default {
}
},
paintRegion(value) {
this.brushCursor.visible = false;
if (this.controls) this.controls.enabled = !value;
},
async modelSrc() {
@@ -339,7 +348,7 @@ export default {
// Camera erstellen
const aspect = container.clientWidth / container.clientHeight;
this.camera = markRaw(new runtime.PerspectiveCamera(50, aspect, 0.1, 1000));
this.camera = markRaw(new runtime.PerspectiveCamera(50, aspect, this.editable ? 0.005 : 0.1, 1000));
// Näher ran, wenn der Container sehr schmal ist (compact views)
const camZ = container.clientWidth && container.clientWidth < 120 ? 2.2 : 3;
this.camera.position.set(0, 1.6, camZ);
@@ -523,7 +532,12 @@ export default {
this.sourceAnimations = markRaw(gltf.animations || []);
this.undoHistory = [];
this.painting = false;
if (this.editable) modelRuntime.prepareAppearanceEditor(this.model);
if (this.editable) await modelRuntime.prepareAppearanceEditor(this.model);
else await modelRuntime.initializeCharacterAppearance(this.model);
if (loadVersion !== this.modelLoadVersion || !this.scene) {
modelRuntime.disposeCharacterModel(gltf.scene);
return;
}
modelRuntime.applyCharacterAppearance(this.model, this.appearance);
if (this.controls) {
this.controls.enabled = !this.paintRegion;
@@ -611,7 +625,7 @@ export default {
this.camera.position.set(0, camY, camZ);
if (this.controls) {
this.controls.target.set(scaledCenter.x, scaledCenter.y, scaledCenter.z);
this.controls.minDistance = Math.max(0.8, distance * 0.55);
this.controls.minDistance = this.editable ? 0.04 : Math.max(0.8, distance * 0.55);
this.controls.maxDistance = Math.max(this.controls.minDistance + 1, distance * 3.2);
this.controls.update();
} else {
@@ -661,25 +675,37 @@ export default {
this.paintAtEvent(event);
},
continuePainting(event) {
if (!this.painting || performance.now() - this.lastPaintAt < 50) return;
this.paintAtEvent(event);
if (!this.editable || !this.paintRegion || performance.now() - this.lastPaintAt < 35) return;
if (this.painting) this.paintAtEvent(event);
else { this.lastPaintAt = performance.now(); this.getPaintHit(event); }
},
finishPainting(event) {
this.painting = false;
if (this.model && this.threeModelRuntime) this.threeModelRuntime.finishAppearanceStroke(this.model);
if (this.$refs.container?.hasPointerCapture(event.pointerId)) this.$refs.container.releasePointerCapture(event.pointerId);
},
paintAtEvent(event) {
getPaintHit(event) {
const runtime = this.threeModelRuntime;
if (!runtime || !this.model || !this.renderer) return;
this.lastPaintAt = performance.now();
if (!runtime || !this.model || !this.renderer) return null;
const rect = this.renderer.domElement.getBoundingClientRect();
const pointer = new runtime.Vector2((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1);
const raycaster = new runtime.Raycaster();
raycaster.setFromCamera(pointer, this.camera);
const hit = raycaster.intersectObject(this.model, true)[0];
if (!hit) return;
if (!hit) { this.brushCursor.visible = false; return null; }
const height = new runtime.Box3().setFromObject(this.model).getSize(new runtime.Vector3()).y;
if (runtime.paintAppearanceRegion(hit, Math.max(0.001, this.brushSize) * height, this.paintRegion, raycaster.ray.direction)) {
const radius = Math.max(0.0002, this.brushSize) * height;
const distance = this.camera.position.distanceTo(hit.point);
const diameter = radius * rect.height / (Math.max(0.001, distance) * Math.tan(this.camera.fov * Math.PI / 360));
this.brushCursor = { visible: true, x: event.clientX - rect.left, y: event.clientY - rect.top, diameter: Math.max(2, diameter) };
return { runtime, hit, radius, direction: raycaster.ray.direction };
},
paintAtEvent(event) {
this.lastPaintAt = performance.now();
const brush = this.getPaintHit(event);
if (!brush) return;
const { runtime, hit, radius, direction } = brush;
if (runtime.paintAppearanceRegion(hit, radius, this.paintRegion, direction)) {
runtime.applyCharacterAppearance(this.model, this.appearance);
this.$emit('appearance-changed', runtime.getAppearanceStats(this.model));
}
@@ -690,6 +716,14 @@ export default {
this.threeModelRuntime.restoreAppearanceEditor(snapshot);
this.$emit('appearance-changed', this.threeModelRuntime.getAppearanceStats(this.model));
},
clearAppearance() {
if (!this.model || !this.editable) return;
this.undoHistory.push(markRaw(this.threeModelRuntime.captureAppearanceEditor(this.model)));
if (this.undoHistory.length > 10) this.undoHistory.shift();
this.threeModelRuntime.clearAppearanceMarks(this.model);
this.threeModelRuntime.finishAppearanceStroke(this.model);
this.$emit('appearance-changed', this.threeModelRuntime.getAppearanceStats(this.model));
},
async exportAppearanceModel() {
if (!this.model || !this.editable) throw new Error('Bitte zuerst ein Modell laden.');
const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
@@ -697,6 +731,7 @@ export default {
root.position.copy(this.originalTransform.position);
root.quaternion.copy(this.originalTransform.quaternion);
root.scale.copy(this.originalTransform.scale);
this.threeModelRuntime.serializeAppearanceMasks(root);
const binary = await new GLTFExporter().parseAsync(root, { binary: true, animations: this.sourceAnimations });
return new Blob([binary], { type: 'model/gltf-binary' });
},
@@ -821,6 +856,17 @@ export default {
object-position: center bottom;
}
.appearance-brush-cursor {
position: absolute;
border: 1px solid white;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .8);
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
box-sizing: border-box;
z-index: 2;
}
.character-debug-overlay {
position: absolute;
right: 6px;

View File

@@ -28,9 +28,10 @@
</div>
<p class="appearance-editor__note">{{ toolHint }}</p>
<label v-if="mode !== 'rotate'">Pinselgröße
<input v-model.number="brushSize" type="range" min="0.002" max="0.06" step="0.001" />
<input v-model.number="brushSize" type="range" min="0.0005" max="0.06" step="0.0005" />
</label>
<button type="button" :disabled="!loaded" @click="$refs.character?.undoAppearance()">Markierung rückgängig</button>
<button type="button" :disabled="!loaded" @click="$refs.character?.clearAppearance()">Alle Markierungen löschen</button>
</fieldset>
<fieldset>
<legend>Farben ausprobieren</legend>
@@ -86,7 +87,7 @@ export default {
paintRegion() { return this.mode === 'rotate' ? null : this.mode; },
toolHint() {
if (this.mode === 'rotate') return 'Ziehen zum Drehen, Mausrad zum Zoomen, rechte Maustaste zum Verschieben.';
if (this.mode === 'iris') return 'Nah heranzoomen und nur die farbige Iris markieren. Pupille und Augenweiß aussparen.';
if (this.mode === 'iris') return 'Mit kleinem Pinsel nur die farbige Iris markieren. Der Kreis zeigt die Pinselgröße; Pupille und Augenweiß aussparen.';
if (this.mode === 'body') return 'Über eine Fläche streichen, um dort die ursprüngliche Textur wiederherzustellen.';
return 'Über die sichtbaren Haarflächen streichen. Für Rückseite und Seiten wieder auf Drehen wechseln.';
},

View File

@@ -1,8 +1,10 @@
import { Color, Vector3 } from 'three';
import { Color, DataTexture, LinearFilter, RGBAFormat, Vector3 } from 'three';
export const APPEARANCE_REGIONS = Object.freeze(['body', 'hair', 'iris']);
const editorStates = new WeakMap();
const colorStates = new WeakMap();
const DEFAULT_MASK_SIZE = 2048;
const TILE_SIZE = 64;
const materialStates = new WeakMap();
const editableMeshes = new WeakSet();
export function getAppearanceRegion(material) {
const region = material?.userData?.falukantAppearance?.region;
@@ -13,186 +15,314 @@ export function getAppearanceRegion(material) {
return 'body';
}
function setupColor(material) {
if (colorStates.has(material)) return colorStates.get(material);
const uniforms = { target: { value: new Color() }, enabled: { value: 0 } };
function makeMask(width, height, region = 'body') {
const data = new Uint8Array(width * height * 4);
for (let i = 0; i < data.length; i += 4) {
data[i] = region === 'hair' ? 255 : 0;
data[i + 1] = region === 'iris' ? 255 : 0;
data[i + 3] = 255;
}
return { data, width, height };
}
async function decodeMask(uri, maxSize) {
if (!/^data:image\/png;base64,/.test(uri)) throw new Error('Die Farbmaske muss als PNG im GLB gespeichert sein.');
const image = new Image();
image.src = uri;
await image.decode();
if (!image.width || !image.height || image.width > 4096 || image.height > 4096) {
throw new Error('Die Farbmaske hat eine ungültige Auflösung.');
}
const canvas = document.createElement('canvas');
const ratio = Math.min(1, maxSize / Math.max(image.width, image.height));
canvas.width = Math.max(1, Math.round(image.width * ratio));
canvas.height = Math.max(1, Math.round(image.height * ratio));
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return { width: canvas.width, height: canvas.height, data: new Uint8Array(context.getImageData(0, 0, canvas.width, canvas.height).data) };
}
function countMask(state) {
state.hair = 0;
state.iris = 0;
state.marked = 0;
for (let i = 0; i < state.data.length; i += 4) {
if (state.data[i]) state.hair++;
if (state.data[i + 1]) state.iris++;
if (state.data[i] || state.data[i + 1]) state.marked++;
}
}
async function initializeMaterial(material, editable, maskSize) {
if (materialStates.has(material)) return;
const profile = material.userData?.falukantAppearance;
const mask = profile?.maskPng
? await decodeMask(profile.maskPng, editable ? 4096 : 2048)
: makeMask(editable ? maskSize : 1, editable ? maskSize : 1, getAppearanceRegion(material));
const texture = new DataTexture(mask.data, mask.width, mask.height, RGBAFormat);
// glTF UVs and the serialized PNG use the same top-to-bottom row order.
texture.flipY = false;
texture.minFilter = LinearFilter;
texture.magFilter = LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
const state = {
...mask, texture, stroke: null,
uniforms: {
falukantRegionMask: { value: texture },
falukantHairColor: { value: new Color() },
falukantIrisColor: { value: new Color() },
falukantHairEnabled: { value: 0 },
falukantIrisEnabled: { value: 0 },
},
};
countMask(state);
materialStates.set(material, state);
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}`;
Object.assign(shader.uniforms, state.uniforms);
shader.vertexShader = `varying vec2 vFalukantUv;\n${shader.vertexShader}`;
shader.vertexShader = shader.vertexShader.replace('#include <uv_vertex>', '#include <uv_vertex>\nvFalukantUv = uv;');
shader.fragmentShader = `varying vec2 vFalukantUv;
uniform sampler2D falukantRegionMask;
uniform vec3 falukantHairColor;
uniform vec3 falukantIrisColor;
uniform float falukantHairEnabled;
uniform float falukantIrisEnabled;\n${shader.fragmentShader}`;
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', `
#include <map_fragment>
vec2 falukantMask = clamp(texture2D(falukantRegionMask, vFalukantUv).rg, 0.0, 1.0);
float falukantHairWeight = falukantMask.r * falukantHairEnabled;
float falukantIrisWeight = min(falukantMask.g, 1.0 - falukantMask.r) * falukantIrisEnabled;
float falukantKeep = 1.0 - falukantHairWeight - falukantIrisWeight;
vec3 falukantTint = falukantHairColor * falukantHairWeight + falukantIrisColor * falukantIrisWeight;
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);
diffuseColor.rgb = diffuseColor.rgb * falukantKeep + falukantTint * falukantDetail;
`);
// 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);
totalEmissiveRadiance = totalEmissiveRadiance * falukantKeep + falukantTint * falukantEmission;
`);
};
material.customProgramCacheKey = () => 'falukant-appearance-v1';
material.customProgramCacheKey = () => 'falukant-appearance-uv-mask-v2';
material.needsUpdate = true;
colorStates.set(material, uniforms);
return uniforms;
}
export async function initializeCharacterAppearance(model, { editable = false, maskSize = null } = {}) {
const clones = new Map();
const pending = [];
model.traverse(mesh => {
if (!mesh.isMesh) return;
const materials = [].concat(mesh.material);
const prepared = materials.map(material => {
if (!editable && !material.userData?.falukantAppearance?.maskPng && getAppearanceRegion(material) === 'body') return material;
if (materialStates.has(material)) return material;
if (!clones.has(material)) {
const clone = material.clone();
clones.set(material, clone);
const size = maskSize ?? Math.min(4096, Math.max(DEFAULT_MASK_SIZE, material.map?.image?.width || 0, material.map?.image?.height || 0));
pending.push(initializeMaterial(clone, editable, size));
}
return clones.get(material);
});
mesh.material = Array.isArray(mesh.material) ? prepared : prepared[0];
if (editable && mesh.geometry.getAttribute('uv')) editableMeshes.add(mesh);
});
await Promise.all(pending);
if (editable) model.userData.falukantAppearance = { version: 2, regions: APPEARANCE_REGIONS.slice() };
}
export async function prepareAppearanceEditor(model, options = {}) {
return initializeCharacterAppearance(model, { ...options, editable: true });
}
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 state = materialStates.get(material);
if (!state) continue;
for (const [field, colorUniform, enabledUniform] of [
['hairColor', 'falukantHairColor', 'falukantHairEnabled'],
['eyeColor', 'falukantIrisColor', 'falukantIrisEnabled'],
]) {
const color = appearance[field];
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);
state.uniforms[enabledUniform].value = valid ? 1 : 0;
if (valid) state.uniforms[colorUniform].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) {
function eachState(model, callback) {
const seen = new Set();
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));
if (!mesh.isMesh) return;
for (const material of [].concat(mesh.material)) {
const state = materialStates.get(material);
if (state && !seen.has(state)) { seen.add(state); callback(state, material); }
}
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);
function rememberTile(state, x, y) {
if (!state.stroke) return;
const tileX = Math.floor(x / TILE_SIZE) * TILE_SIZE;
const tileY = Math.floor(y / TILE_SIZE) * TILE_SIZE;
const key = tileY * state.width + tileX;
if (state.stroke.tiles.has(key)) return;
const width = Math.min(TILE_SIZE, state.width - tileX);
const height = Math.min(TILE_SIZE, state.height - tileY);
const data = new Uint8Array(width * height * 4);
for (let row = 0; row < height; row++) {
const start = ((tileY + row) * state.width + tileX) * 4;
data.set(state.data.subarray(start, start + width * 4), row * width * 4);
}
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;
state.stroke.tiles.set(key, { x: tileX, y: tileY, width, height, data });
}
function paintPixel(state, x, y, label, coverage) {
const offset = (y * state.width + x) * 4;
const red = state.data[offset], green = state.data[offset + 1];
const nextRed = Math.round(red * (1 - coverage) + (label === 'hair' ? 255 : 0) * coverage);
const nextGreen = Math.round(green * (1 - coverage) + (label === 'iris' ? 255 : 0) * coverage);
if (red === nextRed && green === nextGreen) return false;
rememberTile(state, x, y);
state.hair += Number(nextRed > 0) - Number(red > 0);
state.iris += Number(nextGreen > 0) - Number(green > 0);
state.marked += Number(nextRed > 0 || nextGreen > 0) - Number(red > 0 || green > 0);
state.data[offset] = nextRed;
state.data[offset + 1] = nextGreen;
return true;
}
// Rasterize the surface brush into the UV mask, not into whole mesh faces.
// Barycentric world positions keep the brush circular even across UV seams.
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;
if (!editableMeshes.has(mesh) || !APPEARANCE_REGIONS.includes(region) || worldRadius <= 0) return false;
const geometry = mesh.geometry;
const positions = geometry.getAttribute('position'), uv = geometry.getAttribute('uv');
if (!uv) 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();
const index = geometry.index;
const groups = Array.isArray(mesh.material) && geometry.groups.length
? geometry.groups : [{ start: 0, count: index ? index.count : positions.count, materialIndex: 0 }];
const materials = [].concat(mesh.material);
const a = new Vector3(), b = new Vector3(), c = new Vector3(), normal = new Vector3(), edge = 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);
const touched = new Set();
for (const group of groups) {
const state = materialStates.get(materials[group.materialIndex || 0]);
if (!state) continue;
for (let triangle = group.start; triangle < group.start + group.count; triangle += 3) {
const ia = index ? index.getX(triangle) : triangle;
const ib = index ? index.getX(triangle + 1) : triangle + 1;
const ic = index ? index.getX(triangle + 2) : triangle + 2;
a.fromBufferAttribute(positions, ia); b.fromBufferAttribute(positions, ib); c.fromBufferAttribute(positions, ic);
if (Math.max(a.x, b.x, c.x) < point.x - radius || Math.min(a.x, b.x, c.x) > point.x + radius
|| Math.max(a.y, b.y, c.y) < point.y - radius || Math.min(a.y, b.y, c.y) > point.y + radius
|| Math.max(a.z, b.z, c.z) < point.z - radius || Math.min(a.z, b.z, c.z) > point.z + radius) continue;
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 (normal.dot(direction) >= 0 && triangle / 3 !== hit.faceIndex) continue;
const au = uv.getX(ia), av = uv.getY(ia), bu = uv.getX(ib), bv = uv.getY(ib), cu = uv.getX(ic), cv = uv.getY(ic);
const determinant = (bv - cv) * (au - cu) + (cu - bu) * (av - cv);
if (Math.abs(determinant) < 1e-12) continue;
const minX = Math.max(0, Math.floor(Math.min(au, bu, cu) * state.width));
const maxX = Math.min(state.width - 1, Math.ceil(Math.max(au, bu, cu) * state.width));
const minY = Math.max(0, Math.floor(Math.min(av, bv, cv) * state.height));
const maxY = Math.min(state.height - 1, Math.ceil(Math.max(av, bv, cv) * state.height));
for (let y = minY; y <= maxY; y++) for (let x = minX; x <= maxX; x++) {
const u = (x + .5) / state.width, v = (y + .5) / state.height;
const wa = ((bv - cv) * (u - cu) + (cu - bu) * (v - cv)) / determinant;
const wb = ((cv - av) * (u - cu) + (au - cu) * (v - cv)) / determinant;
const wc = 1 - wa - wb;
if (wa < -1e-7 || wb < -1e-7 || wc < -1e-7) continue;
const dx = wa * a.x + wb * b.x + wc * c.x - point.x;
const dy = wa * a.y + wb * b.y + wc * c.y - point.y;
const dz = wa * a.z + wb * b.z + wc * c.z - point.z;
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (distance > radius) continue;
const t = Math.min(1, (radius - distance) / (radius * .18));
const coverage = t * t * (3 - 2 * t);
if (paintPixel(state, x, y, region, coverage)) { changed = true; touched.add(state); }
}
}
if (changed) rebuildGroups(mesh, state);
}
// At extreme zoom a brush may be smaller than one mask texel. Still paint
// just that texel; never expand the mark to the entire intersected triangle.
if (!changed && hit.uv) {
const materialIndex = hit.face?.materialIndex || 0;
const state = materialStates.get(materials[materialIndex]);
if (state) {
const x = Math.max(0, Math.min(state.width - 1, Math.floor(hit.uv.x * state.width)));
const y = Math.max(0, Math.min(state.height - 1, Math.floor(hit.uv.y * state.height)));
if (paintPixel(state, x, y, region, 1)) { changed = true; touched.add(state); }
}
}
touched.forEach(state => { state.texture.needsUpdate = true; });
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() });
eachState(model, state => {
const record = { state, hair: state.hair, iris: state.iris, marked: state.marked, tiles: new Map() };
state.stroke = record;
snapshot.push(record);
});
return snapshot;
}
export function finishAppearanceStroke(model) { eachState(model, state => { state.stroke = null; }); }
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);
for (const record of snapshot) {
const { state } = record;
for (const tile of record.tiles.values()) {
for (let row = 0; row < tile.height; row++) {
state.data.set(tile.data.subarray(row * tile.width * 4, (row + 1) * tile.width * 4), ((tile.y + row) * state.width + tile.x) * 4);
}
}
state.hair = record.hair; state.iris = record.iris; state.marked = record.marked;
state.stroke = null;
state.texture.needsUpdate = true;
}
}
export function clearAppearanceMarks(model) {
eachState(model, state => {
for (let offset = 0; offset < state.data.length; offset += 4) {
if (state.data[offset] || state.data[offset + 1]) {
const pixel = offset / 4;
paintPixel(state, pixel % state.width, Math.floor(pixel / state.width), 'body', 1);
}
}
state.texture.needsUpdate = true;
});
}
export function serializeAppearanceMasks(model) {
eachState(model, (state, material) => {
const canvas = document.createElement('canvas');
canvas.width = state.width; canvas.height = state.height;
const context = canvas.getContext('2d');
const image = context.createImageData(state.width, state.height);
image.data.set(state.data);
context.putImageData(image, 0, 0);
material.userData.falukantAppearance = { version: 2, region: 'masked', uvSet: 0, maskPng: canvas.toDataURL('image/png') };
});
}
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;
}
});
eachState(model, state => { stats.hair += state.hair; stats.iris += state.iris; stats.body += state.width * state.height - state.marked; });
return stats;
}
@@ -203,11 +333,10 @@ export function disposeCharacterModel(model) {
for (const material of [].concat(object.material || [])) materials.add(material);
});
for (const material of materials) {
const state = materialStates.get(material);
if (state) textures.add(state.texture);
for (const value of Object.values(material)) {
if (value?.isTexture) {
textures.add(value);
if (value.source?.data) images.add(value.source.data);
}
if (value?.isTexture) { textures.add(value); if (value.source?.data) images.add(value.source.data); }
}
material.dispose();
}