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, Die vorhandenen Meshy-GLBs enthalten ein gemeinsames Mesh und Material für Haut,
Kleidung, Haare und Augen. Eine Änderung der allgemeinen Materialfarbe würde Kleidung, Haare und Augen. Eine Änderung der allgemeinen Materialfarbe würde
deshalb die gesamte Figur färben. Die Bereiche müssen einmal pro Modell alle Bereiche färben. Die Zuordnung erfolgt deshalb über eine eigene UV-Maske:
zugeordnet werden; die Geometrie wird dabei nicht anhand von Farbähnlichkeiten Rot markiert Haare, Grün die Iris, Schwarz unveränderte Bereiche.
automatisch geraten.
Unter `/falukant/dev-all-characters` öffnet sich jetzt standardmäßig die Die erste Version verwendete ganze Dreiecke als Markierung. Gerade an Augen
Vorschau **Farben und Modellbereiche**. Die bisherige Altersübersicht bleibt waren diese zu grob. Version 2 bemalt stattdessen einzelne Texturpixel. Die
über **Alle Altersmodelle** erreichbar. 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 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` möglichst die Quelldatei aus `YourPart3Assets/models-src/3d/falukant/characters`
statt eines bereits reduzierten Laufzeitmodells verwenden. statt eines bereits reduzierten Laufzeitmodells verwenden.
2. In **Drehen / Zoom** das Modell ausrichten. Das Mausrad zoomt, die rechte 2. In **Drehen / Zoom** ausrichten. Das Mausrad zoomt bis zur Nahansicht; die
Maustaste verschiebt die Ansicht. rechte Maustaste verschiebt die Ansicht. Die Editor-Kamera erlaubt auch das
3. **Haare markieren** wählen und sichtbare Haarflächen überstreichen. Zum genaue Betrachten der Iris, ohne dass die Nah-Clipping-Ebene sie abschneidet.
Erreichen weiterer Seiten wieder in den Drehmodus wechseln. 3. **Haare markieren** wählen und über die sichtbaren Haarflächen streichen.
4. Für **Iris markieren** nahe heranzoomen und einen kleinen Pinsel verwenden. Zum Erreichen anderer Seiten wieder in den Drehmodus wechseln.
Augenweiß, Pupillen, Lider und Wimpern aussparen. 4. Für **Iris markieren** einen kleinen Pinsel verwenden. Der sichtbare Kreis
5. Mit **Markierung entfernen** Bereiche auf ihre ursprüngliche Textur zeigt die Pinselgröße. Pupille, Augenweiß, Lider und Wimpern aussparen.
zurücksetzen. Die letzten zehn Pinselstriche lassen sich rückgängig machen. 5. **Markierung entfernen** radiert lokal. **Alle Markierungen löschen** entfernt
6. Augen-/Haarfarbe ändern und die Zuordnung prüfen. Die Checkbox zeigt zum die gesamte Zuordnung, etwa zum Ersetzen alter dreieckiger Markierungen.
Vergleich die Originalfarben. Haut und Kleidung werden nicht eingefärbt. Die letzten zehn Pinselstriche bzw. Löschaktionen sind rückgängig machbar.
7. Das bearbeitete GLB herunterladen und erneut öffnen, um die Zuordnung zu 6. Farben ausprobieren. Die Checkbox zeigt zum Vergleich die Originalfarben.
kontrollieren. Exportiert werden die Materialbereiche mit ihren ursprünglichen Außerhalb der Maske bleiben Haut und Kleidung unverändert.
Texturen, nicht die gewählten Vorschaufarben. 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. Originaldateien im Asset-Repository werden nicht automatisch überschrieben.
Nach Kontrolle die heruntergeladene Datei unter dem bisherigen Quellnamen im Nach Kontrolle die Datei unter dem bisherigen Quellnamen dort ablegen, z. B.
Asset-Repository ablegen, z. B. `female_20y.glb`, und das Asset-Repository committen. `female_20y.glb`, und das Asset-Repository committen. Die bestehende Pipeline
Die bestehende Pipeline `npm run build-runtime-models -- --assets-dir `npm run build-runtime-models -- --assets-dir ../YourPart3Assets --category characters`
../YourPart3Assets --category characters` erstellt daraus die optimierten GLBs. erstellt daraus die optimierten GLBs. Die Exportdatei erhält keinen `_opt`-Suffix,
Die Exportdatei erhält bewusst keinen `_opt`-Suffix, da solche Dateien vom da solche Dateien vom Quellmodell-Build übersprungen werden.
Quellmodell-Build übersprungen werden.
Noch nicht markierte Modelle behalten ihr bisheriges Aussehen. Diese Änderung Unmarkierte Modelle behalten ihr bisheriges Aussehen. Die vollständige
ersetzt nicht automatisch alle bestehenden GLBs. Es gibt keine neue Markierung aller Modellvarianten ist eine einmalige Aufgabe je Modell. Es gibt
Datenbankmigration oder Speicherung pro Spielcharakter. keine neue Datenbankmigration oder Speicherung pro Spielcharakter.
## Schnittstelle für die Anzeige ## Schnittstelle für die Anzeige
@@ -53,42 +56,55 @@ Datenbankmigration oder Speicherung pro Spielcharakter.
/> />
``` ```
Nur sechsstellige Hexfarben werden angewendet. `null` oder ein fehlender Wert Nur sechsstellige Hexfarben werden angewendet. Fehlende Werte oder `null`
zeigt für den jeweiligen Bereich die Originaltextur. Farbänderungen benötigen zeigen die Originaltextur. Farbänderungen benötigen keinen neuen Modell-Download.
keinen erneuten Download und keine neue Modellinstanz.
Materialien enthalten glTF-`extras` in dieser Form: Materialien enthalten glTF-`extras` in dieser Form:
```json ```json
{ {
"falukantAppearance": { "falukantAppearance": {
"version": 1, "version": 2,
"region": "hair", "region": "masked",
"source": "material-0" "uvSet": 0,
"maskPng": "data:image/png;base64,..."
} }
} }
``` ```
Bereiche sind `body`, `hair` und `iris`. `source` ordnet die Bereiche dem Die Maske liegt als PNG direkt im GLB. Neue Masken verwenden passend zur
ursprünglichen Material zu. Alternativ werden für extern vorbereitete Modelle Originaltextur 2048×2048 bis 4096×4096 Pixel. Sie wird
die exakten Materialnamen `hair`, `iris`, `falukant_hair` und `falukant_iris` nicht in die Farbtextur eingebrannt und benötigt keinen eigenen Server-Endpunkt.
erkannt. Die Standardmaterialien und Texturkoordinaten bleiben erhalten. Die bestehende Draco-/Textur-Optimierung erhält die Material-Extras und verändert
Der Pinsel teilt Dreiecke in Materialgruppen; die Genauigkeit hängt deshalb die Maske nicht. Beim Laden wird sie als lineare Datentextur ohne Y-Spiegelung
von der vorhandenen Geometrie ab. Sehr grobe Augenbereiche benötigen eine interpretiert, passend zu den glTF-UVs. Für die normale Spielanzeige wird sie
feinere Geometrie oder eine spätere Texturmaskierung. 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 Der Pinsel rastert UV-Dreiecke anhand ihrer baryzentrischen Oberflächenpositionen.
Farben in emissiven Texturen. Shader-Uniforms werden pro Material gehalten und Damit bleibt seine Größe in der Modelloberfläche konsistent und er kann über
nicht als Laufzeitobjekte in das GLB geschrieben. Der Export stellt die originale UV-Nähte hinweg malen. Er färbt keine ganzen Dreiecke als Ersatz für kleine
Wurzeltransformation wieder her und übernimmt vorhandene Animationen. 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 ## Spätere altersabhängige Attribute
Ergrauen kann später über eine altersabhängige Haarfarbe auf derselben Ergrauen kann über eine altersabhängige Haarfarbe aufsetzen. Für Wachstum oder
Schnittstelle aufsetzen. Für Wachstum oder Veränderungen der Gesichtsform Veränderungen der Gesichtsform braucht es Morph-Targets oder austauschbare
braucht es gezielt vorbereitete Morph-Targets oder austauschbare Geometrie. Geometrie. Die bisherigen unabhängigen Altersmodelle werden durch die Maskierung
Die bisherigen voneinander unabhängigen Altersmodelle werden durch die nicht automatisch zu einem verformbaren Basismodell.
Farbzuordnung nicht automatisch zu einem verformbaren Basismodell.
## Prüfung ## Prüfung
@@ -98,7 +114,8 @@ npm run test:character-appearance
npm run build npm run build
``` ```
Zusätzlich wurde ein vorhandenes `female_20y.glb` im Browser geladen, mit Haar- Regressionstests prüfen kleine Iris-Pinsel auf großen Dreiecken, unveränderte
und Irisbereichen versehen, exportiert, mit der bestehenden Draco-/Simplify- Geometrie, UV-Nähte, unabhängige Farben/Instanzen, lokales Radieren, Rückgängig
Pipeline optimiert und erneut importiert. Beide Bereiche und ihre glTF-Metadaten und Version-1-Kompatibilität. Im Browser wird zusätzlich das vorhandene
blieben erhalten. Die vollständige Markierung aller Modellvarianten steht noch aus. `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 assert from 'node:assert/strict';
import test from 'node:test'; 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 { import {
applyCharacterAppearance, captureAppearanceEditor, getAppearanceRegion, applyCharacterAppearance, captureAppearanceEditor, clearAppearanceMarks, finishAppearanceStroke,
getAppearanceStats, paintAppearanceRegion, prepareAppearanceEditor, restoreAppearanceEditor, getAppearanceStats, paintAppearanceRegion, prepareAppearanceEditor, restoreAppearanceEditor,
} from '../src/utils/characterAppearance.js'; } 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 model = new Group();
const mesh = new Mesh(new BoxGeometry(), material); const mesh = new Mesh(geometry, material);
model.add(mesh); model.add(mesh);
model.updateMatrixWorld(true); model.updateMatrixWorld(true);
return { model, mesh }; 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 { model, mesh } = fixture();
const original = mesh.geometry; const original = mesh.geometry;
const originalIndex = original.index.array.slice();
const originalUV = original.getAttribute('uv').array.slice(); const originalUV = original.getAttribute('uv').array.slice();
prepareAppearanceEditor(model); await prepareAppearanceEditor(model, { maskSize: 128 });
assert.notEqual(mesh.geometry, original);
assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 });
const snapshot = captureAppearanceEditor(model); const snapshot = captureAppearanceEditor(model);
const indexBuffer = mesh.geometry.index; assert.equal(paint(mesh, new Vector3(.35, .1, 0)), true);
assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3(.5, 0, 0) }, 0, 'hair', new Vector3(-1, 0, 0)), true); const stats = getAppearanceStats(model);
assert.deepEqual(getAppearanceStats(model), { body: 11, hair: 1, iris: 0 }); 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.deepEqual(mesh.geometry.getAttribute('uv').array, originalUV);
assert.equal(mesh.geometry.index, indexBuffer); finishAppearanceStroke(model);
assert.equal(original.groups.length, 6);
restoreAppearanceEditor(snapshot); restoreAppearanceEditor(snapshot);
assert.deepEqual(getAppearanceStats(model), { body: 12, hair: 0, iris: 0 }); assert.deepEqual(getAppearanceStats(model), { body: 128 * 128, hair: 0, iris: 0 });
assert.equal(paintAppearanceRegion({ object: mesh, faceIndex: 0, point: new Vector3() }, 1, 'unknown', new Vector3()), false);
}); });
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 originalMaterial = new MeshStandardMaterial();
const first = fixture(originalMaterial); const first = fixture(originalMaterial), second = fixture(originalMaterial);
const second = fixture(originalMaterial); await prepareAppearanceEditor(first.model, { maskSize: 32 });
prepareAppearanceEditor(first.model); await prepareAppearanceEditor(second.model, { maskSize: 32 });
prepareAppearanceEditor(second.model);
applyCharacterAppearance(first.model, { hairColor: '#ff0000', eyeColor: '#0000ff' }); applyCharacterAppearance(first.model, { hairColor: '#ff0000', eyeColor: '#0000ff' });
applyCharacterAppearance(second.model, { hairColor: '#00ff00' }); applyCharacterAppearance(second.model, { hairColor: '#00ff00' });
const shader = () => ({ uniforms: {}, fragmentShader: '#include <map_fragment>\n#include <emissivemap_fragment>' }); const firstShader = shaderFor(first.mesh.material), secondShader = shaderFor(second.mesh.material);
const hairShader = shader(), irisShader = shader(), otherShader = shader(); assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'ff0000');
first.mesh.material[1].onBeforeCompile(hairShader); assert.equal(firstShader.uniforms.falukantIrisColor.value.getHexString(), '0000ff');
first.mesh.material[2].onBeforeCompile(irisShader); assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00');
second.mesh.material[1].onBeforeCompile(otherShader); assert.notEqual(firstShader.uniforms.falukantRegionMask.value, secondShader.uniforms.falukantRegionMask.value);
assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'ff0000'); paint(first.mesh, new Vector3(), .2, 'hair');
assert.equal(irisShader.uniforms.falukantTargetColor.value.getHexString(), '0000ff'); assert.equal(getAppearanceStats(second.model).hair, 0);
assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00');
applyCharacterAppearance(first.model, { hairColor: '#cccccc', eyeColor: null }); applyCharacterAppearance(first.model, { hairColor: '#cccccc', eyeColor: null });
assert.equal(hairShader.uniforms.falukantTargetColor.value.getHexString(), 'cccccc'); assert.equal(firstShader.uniforms.falukantHairColor.value.getHexString(), 'cccccc');
assert.equal(irisShader.uniforms.falukantColorEnabled.value, 0); assert.equal(firstShader.uniforms.falukantIrisEnabled.value, 0);
assert.equal(otherShader.uniforms.falukantTargetColor.value.getHexString(), '00ff00'); assert.equal(secondShader.uniforms.falukantHairColor.value.getHexString(), '00ff00');
applyCharacterAppearance(first.model, { hairColor: 'invalid' });
assert.equal(hairShader.uniforms.falukantColorEnabled.value, 0);
}); });
test('saved glTF material metadata can be prepared again without multiplying materials', () => { test('an iris brush spans separate UV islands along a shared surface edge', async () => {
const first = fixture(); const geometry = new BufferGeometry();
prepareAppearanceEditor(first.model); geometry.setAttribute('position', new BufferAttribute(new Float32Array([
const model = new Group(); -1, -1, 0, 1, -1, 0, -1, 1, 0,
const mesh = new Mesh(first.mesh.geometry.clone(), first.mesh.material.map(material => { 1, -1, 0, 1, 1, 0, -1, 1, 0,
const copy = material.clone(); ]), 3));
copy.userData = JSON.parse(JSON.stringify(material.userData)); geometry.setAttribute('uv', new BufferAttribute(new Float32Array([
return copy; 0, 0, .4, 0, 0, 1,
})); .6, 0, 1, 1, .6, 1,
model.add(mesh); ]), 2));
prepareAppearanceEditor(model); const { model, mesh } = fixture(undefined, geometry);
assert.equal(mesh.material.length, 3); await prepareAppearanceEditor(model, { maskSize: 128 });
assert.equal(getAppearanceRegion(mesh.material[1]), 'hair'); assert.equal(paint(mesh, new Vector3(), .3), true);
assert.equal(getAppearanceRegion(mesh.material[2]), 'iris'); const { data, width } = shaderFor(mesh.material).uniforms.falukantRegionMask.value.image;
prepareAppearanceEditor(model); const columns = [];
assert.equal(mesh.material.length, 3); 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 class="character-3d-shell">
<div v-show="!showFallback" ref="container" class="character-3d-container" <div v-show="!showFallback" ref="container" class="character-3d-container"
@pointerdown.capture="startPainting" @pointermove="continuePainting" @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 <img
v-if="showFallback" v-if="showFallback"
class="character-fallback" class="character-fallback"
@@ -144,6 +145,7 @@ export default {
undoHistory: [], undoHistory: [],
painting: false, painting: false,
lastPaintAt: 0, lastPaintAt: 0,
brushCursor: { visible: false, x: 0, y: 0, diameter: 0 },
animationId: null, animationId: null,
mixer: null, mixer: null,
clock: null, clock: null,
@@ -169,6 +171,12 @@ export default {
}; };
}, },
computed: { computed: {
brushCursorStyle() {
return {
left: `${this.brushCursor.x}px`, top: `${this.brushCursor.y}px`,
width: `${this.brushCursor.diameter}px`, height: `${this.brushCursor.diameter}px`,
};
},
isDev() { isDev() {
return !import.meta.env.PROD; return !import.meta.env.PROD;
}, },
@@ -226,6 +234,7 @@ export default {
} }
}, },
paintRegion(value) { paintRegion(value) {
this.brushCursor.visible = false;
if (this.controls) this.controls.enabled = !value; if (this.controls) this.controls.enabled = !value;
}, },
async modelSrc() { async modelSrc() {
@@ -339,7 +348,7 @@ export default {
// Camera erstellen // Camera erstellen
const aspect = container.clientWidth / container.clientHeight; 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) // Näher ran, wenn der Container sehr schmal ist (compact views)
const camZ = container.clientWidth && container.clientWidth < 120 ? 2.2 : 3; const camZ = container.clientWidth && container.clientWidth < 120 ? 2.2 : 3;
this.camera.position.set(0, 1.6, camZ); this.camera.position.set(0, 1.6, camZ);
@@ -523,7 +532,12 @@ export default {
this.sourceAnimations = markRaw(gltf.animations || []); this.sourceAnimations = markRaw(gltf.animations || []);
this.undoHistory = []; this.undoHistory = [];
this.painting = false; 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); modelRuntime.applyCharacterAppearance(this.model, this.appearance);
if (this.controls) { if (this.controls) {
this.controls.enabled = !this.paintRegion; this.controls.enabled = !this.paintRegion;
@@ -611,7 +625,7 @@ export default {
this.camera.position.set(0, camY, camZ); this.camera.position.set(0, camY, camZ);
if (this.controls) { if (this.controls) {
this.controls.target.set(scaledCenter.x, scaledCenter.y, scaledCenter.z); 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.maxDistance = Math.max(this.controls.minDistance + 1, distance * 3.2);
this.controls.update(); this.controls.update();
} else { } else {
@@ -661,25 +675,37 @@ export default {
this.paintAtEvent(event); this.paintAtEvent(event);
}, },
continuePainting(event) { continuePainting(event) {
if (!this.painting || performance.now() - this.lastPaintAt < 50) return; if (!this.editable || !this.paintRegion || performance.now() - this.lastPaintAt < 35) return;
this.paintAtEvent(event); if (this.painting) this.paintAtEvent(event);
else { this.lastPaintAt = performance.now(); this.getPaintHit(event); }
}, },
finishPainting(event) { finishPainting(event) {
this.painting = false; 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); if (this.$refs.container?.hasPointerCapture(event.pointerId)) this.$refs.container.releasePointerCapture(event.pointerId);
}, },
paintAtEvent(event) { getPaintHit(event) {
const runtime = this.threeModelRuntime; const runtime = this.threeModelRuntime;
if (!runtime || !this.model || !this.renderer) return; if (!runtime || !this.model || !this.renderer) return null;
this.lastPaintAt = performance.now();
const rect = this.renderer.domElement.getBoundingClientRect(); 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 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(); const raycaster = new runtime.Raycaster();
raycaster.setFromCamera(pointer, this.camera); raycaster.setFromCamera(pointer, this.camera);
const hit = raycaster.intersectObject(this.model, true)[0]; 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; 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); runtime.applyCharacterAppearance(this.model, this.appearance);
this.$emit('appearance-changed', runtime.getAppearanceStats(this.model)); this.$emit('appearance-changed', runtime.getAppearanceStats(this.model));
} }
@@ -690,6 +716,14 @@ export default {
this.threeModelRuntime.restoreAppearanceEditor(snapshot); this.threeModelRuntime.restoreAppearanceEditor(snapshot);
this.$emit('appearance-changed', this.threeModelRuntime.getAppearanceStats(this.model)); 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() { async exportAppearanceModel() {
if (!this.model || !this.editable) throw new Error('Bitte zuerst ein Modell laden.'); if (!this.model || !this.editable) throw new Error('Bitte zuerst ein Modell laden.');
const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js'); const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
@@ -697,6 +731,7 @@ export default {
root.position.copy(this.originalTransform.position); root.position.copy(this.originalTransform.position);
root.quaternion.copy(this.originalTransform.quaternion); root.quaternion.copy(this.originalTransform.quaternion);
root.scale.copy(this.originalTransform.scale); root.scale.copy(this.originalTransform.scale);
this.threeModelRuntime.serializeAppearanceMasks(root);
const binary = await new GLTFExporter().parseAsync(root, { binary: true, animations: this.sourceAnimations }); const binary = await new GLTFExporter().parseAsync(root, { binary: true, animations: this.sourceAnimations });
return new Blob([binary], { type: 'model/gltf-binary' }); return new Blob([binary], { type: 'model/gltf-binary' });
}, },
@@ -821,6 +856,17 @@ export default {
object-position: center bottom; 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 { .character-debug-overlay {
position: absolute; position: absolute;
right: 6px; right: 6px;

View File

@@ -28,9 +28,10 @@
</div> </div>
<p class="appearance-editor__note">{{ toolHint }}</p> <p class="appearance-editor__note">{{ toolHint }}</p>
<label v-if="mode !== 'rotate'">Pinselgröße <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> </label>
<button type="button" :disabled="!loaded" @click="$refs.character?.undoAppearance()">Markierung rückgängig</button> <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>
<fieldset> <fieldset>
<legend>Farben ausprobieren</legend> <legend>Farben ausprobieren</legend>
@@ -86,7 +87,7 @@ export default {
paintRegion() { return this.mode === 'rotate' ? null : this.mode; }, paintRegion() { return this.mode === 'rotate' ? null : this.mode; },
toolHint() { toolHint() {
if (this.mode === 'rotate') return 'Ziehen zum Drehen, Mausrad zum Zoomen, rechte Maustaste zum Verschieben.'; 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.'; 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.'; 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']); export const APPEARANCE_REGIONS = Object.freeze(['body', 'hair', 'iris']);
const editorStates = new WeakMap(); const DEFAULT_MASK_SIZE = 2048;
const colorStates = new WeakMap(); const TILE_SIZE = 64;
const materialStates = new WeakMap();
const editableMeshes = new WeakSet();
export function getAppearanceRegion(material) { export function getAppearanceRegion(material) {
const region = material?.userData?.falukantAppearance?.region; const region = material?.userData?.falukantAppearance?.region;
@@ -13,186 +15,314 @@ export function getAppearanceRegion(material) {
return 'body'; return 'body';
} }
function setupColor(material) { function makeMask(width, height, region = 'body') {
if (colorStates.has(material)) return colorStates.get(material); const data = new Uint8Array(width * height * 4);
const uniforms = { target: { value: new Color() }, enabled: { value: 0 } }; 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; const previousCompile = material.onBeforeCompile;
material.onBeforeCompile = function (shader, renderer) { material.onBeforeCompile = function (shader, renderer) {
previousCompile.call(this, shader, renderer); previousCompile.call(this, shader, renderer);
shader.uniforms.falukantTargetColor = uniforms.target; Object.assign(shader.uniforms, state.uniforms);
shader.uniforms.falukantColorEnabled = uniforms.enabled; shader.vertexShader = `varying vec2 vFalukantUv;\n${shader.vertexShader}`;
shader.fragmentShader = `uniform vec3 falukantTargetColor; shader.vertexShader = shader.vertexShader.replace('#include <uv_vertex>', '#include <uv_vertex>\nvFalukantUv = uv;');
uniform float falukantColorEnabled;\n${shader.fragmentShader}`; 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>', ` shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', `
#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); 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>', ` shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#include <emissivemap_fragment> #include <emissivemap_fragment>
float falukantEmission = dot(totalEmissiveRadiance, vec3(0.2126, 0.7152, 0.0722)); 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; 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 = {}) { export function applyCharacterAppearance(model, appearance = {}) {
model.traverse(object => { model.traverse(object => {
if (!object.isMesh) return; if (!object.isMesh) return;
for (const material of [].concat(object.material || [])) { for (const material of [].concat(object.material || [])) {
const region = getAppearanceRegion(material); const state = materialStates.get(material);
if (region === 'body') continue; if (!state) continue;
const color = region === 'hair' ? appearance.hairColor : appearance.eyeColor; for (const [field, colorUniform, enabledUniform] of [
const state = setupColor(material); ['hairColor', 'falukantHairColor', 'falukantHairEnabled'],
const valid = typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color); ['eyeColor', 'falukantIrisColor', 'falukantIrisEnabled'],
state.enabled.value = valid ? 1 : 0; ]) {
if (valid) state.target.value.set(color); const color = appearance[field];
const valid = typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(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 function eachState(model, callback) {
// triangle indices into standard glTF material groups. No texture guessing. const seen = new Set();
export function prepareAppearanceEditor(model) {
model.traverse(mesh => { model.traverse(mesh => {
if (!mesh.isMesh || editorStates.has(mesh)) return; if (!mesh.isMesh) return;
const original = mesh.geometry; for (const material of [].concat(mesh.material)) {
if (!original.getAttribute('position')) return; const state = materialStates.get(material);
mesh.geometry = original.clone(); if (state && !seen.has(state)) { seen.add(state); callback(state, material); }
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) { function rememberTile(state, x, y) {
const geometry = mesh.geometry; if (!state.stroke) return;
const oldIndex = geometry.index.array; const tileX = Math.floor(x / TILE_SIZE) * TILE_SIZE;
const buckets = Array.from({ length: mesh.material.length }, () => []); const tileY = Math.floor(y / TILE_SIZE) * TILE_SIZE;
for (let face = 0; face < state.regions.length; face++) { const key = tileY * state.width + tileX;
buckets[state.bases[face] * 3 + state.regions[face]].push(face); 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); state.stroke.tiles.set(key, { x: tileX, y: tileY, width, height, data });
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;
} }
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) { export function paintAppearanceRegion(hit, worldRadius, region, cameraDirection) {
const mesh = hit.object; const mesh = hit.object;
const state = editorStates.get(mesh); if (!editableMeshes.has(mesh) || !APPEARANCE_REGIONS.includes(region) || worldRadius <= 0) return false;
const label = APPEARANCE_REGIONS.indexOf(region); const geometry = mesh.geometry;
if (!state || label < 0 || !Number.isInteger(hit.faceIndex)) return false; const positions = geometry.getAttribute('position'), uv = geometry.getAttribute('uv');
if (!uv) return false;
const point = mesh.worldToLocal(hit.point.clone()); const point = mesh.worldToLocal(hit.point.clone());
const scale = mesh.getWorldScale(new Vector3()); const scale = mesh.getWorldScale(new Vector3());
const radius = worldRadius / Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z)); 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 direction = cameraDirection.clone().transformDirection(mesh.matrixWorld.clone().invert());
const positions = mesh.geometry.getAttribute('position'); const index = geometry.index;
const index = mesh.geometry.index.array; const groups = Array.isArray(mesh.material) && geometry.groups.length
const a = new Vector3(), b = new Vector3(), c = new Vector3(); ? geometry.groups : [{ start: 0, count: index ? index.count : positions.count, materialIndex: 0 }];
const normal = new Vector3(), edge = new Vector3(), center = new Vector3(); const materials = [].concat(mesh.material);
const a = new Vector3(), b = new Vector3(), c = new Vector3(), normal = new Vector3(), edge = new Vector3();
let changed = false; let changed = false;
for (let face = 0; face < state.regions.length; face++) { const touched = new Set();
if (state.regions[face] === label) continue; for (const group of groups) {
a.fromBufferAttribute(positions, index[face * 3]); const state = materialStates.get(materials[group.materialIndex || 0]);
b.fromBufferAttribute(positions, index[face * 3 + 1]); if (!state) continue;
c.fromBufferAttribute(positions, index[face * 3 + 2]); for (let triangle = group.start; triangle < group.start + group.count; triangle += 3) {
center.copy(a).add(b).add(c).multiplyScalar(1 / 3); const ia = index ? index.getX(triangle) : triangle;
normal.subVectors(b, a).cross(edge.subVectors(c, a)); const ib = index ? index.getX(triangle + 1) : triangle + 1;
if (face === hit.faceIndex || (center.distanceToSquared(point) <= radius * radius && normal.dot(direction) < 0)) { const ic = index ? index.getX(triangle + 2) : triangle + 2;
state.regions[face] = label; a.fromBufferAttribute(positions, ia); b.fromBufferAttribute(positions, ib); c.fromBufferAttribute(positions, ic);
changed = true; 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 (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; return changed;
} }
export function captureAppearanceEditor(model) { export function captureAppearanceEditor(model) {
const snapshot = []; const snapshot = [];
model.traverse(mesh => { eachState(model, state => {
const state = editorStates.get(mesh); const record = { state, hair: state.hair, iris: state.iris, marked: state.marked, tiles: new Map() };
if (state) snapshot.push({ mesh, index: mesh.geometry.index.clone(), regions: state.regions.slice(), bases: state.bases.slice() }); state.stroke = record;
snapshot.push(record);
}); });
return snapshot; return snapshot;
} }
export function finishAppearanceStroke(model) { eachState(model, state => { state.stroke = null; }); }
export function restoreAppearanceEditor(snapshot) { export function restoreAppearanceEditor(snapshot) {
for (const { mesh, index, regions, bases } of snapshot) { for (const record of snapshot) {
mesh.geometry.index.array.set(index.array); const { state } = record;
mesh.geometry.index.needsUpdate = true; for (const tile of record.tiles.values()) {
const state = { regions: regions.slice(), bases: bases.slice() }; for (let row = 0; row < tile.height; row++) {
editorStates.set(mesh, state); state.data.set(tile.data.subarray(row * tile.width * 4, (row + 1) * tile.width * 4), ((tile.y + row) * state.width + tile.x) * 4);
rebuildGroups(mesh, state); }
}
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) { export function getAppearanceStats(model) {
const stats = { hair: 0, iris: 0, body: 0 }; const stats = { hair: 0, iris: 0, body: 0 };
model.traverse(mesh => { eachState(model, state => { stats.hair += state.hair; stats.iris += state.iris; stats.body += state.width * state.height - state.marked; });
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; return stats;
} }
@@ -203,11 +333,10 @@ export function disposeCharacterModel(model) {
for (const material of [].concat(object.material || [])) materials.add(material); for (const material of [].concat(object.material || [])) materials.add(material);
}); });
for (const material of materials) { for (const material of materials) {
const state = materialStates.get(material);
if (state) textures.add(state.texture);
for (const value of Object.values(material)) { for (const value of Object.values(material)) {
if (value?.isTexture) { if (value?.isTexture) { textures.add(value); if (value.source?.data) images.add(value.source.data); }
textures.add(value);
if (value.source?.data) images.add(value.source.data);
}
} }
material.dispose(); material.dispose();
} }