From dd409a6a6a9677ed2dc767f6a8a3090ae80ff145 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 4 Nov 2026 16:16:01 +0100 Subject: [PATCH] =?UTF-8?q?Ersetze=20grobe=203D-Markierungen=20durch=20pr?= =?UTF-8?q?=C3=A4zise=20UV-Farbmasken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/falukant-character-appearance.md | 127 +++--- .../scripts/test-character-appearance.mjs | 133 +++--- frontend/src/components/Character3D.vue | 68 ++- .../falukant/CharacterAppearanceEditor.vue | 5 +- frontend/src/utils/characterAppearance.js | 393 ++++++++++++------ 5 files changed, 477 insertions(+), 249 deletions(-) diff --git a/frontend/docs/falukant-character-appearance.md b/frontend/docs/falukant-character-appearance.md index f0e4ad8..ff6fd30 100644 --- a/frontend/docs/falukant-character-appearance.md +++ b/frontend/docs/falukant-character-appearance.md @@ -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. diff --git a/frontend/scripts/test-character-appearance.mjs b/frontend/scripts/test-character-appearance.mjs index 385d615..cd545aa 100644 --- a/frontend/scripts/test-character-appearance.mjs +++ b/frontend/scripts/test-character-appearance.mjs @@ -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 ', fragmentShader: '#include \n#include ' }; + 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 \n#include ' }); - 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); }); diff --git a/frontend/src/components/Character3D.vue b/frontend/src/components/Character3D.vue index 3c46e4e..d95d0b6 100755 --- a/frontend/src/components/Character3D.vue +++ b/frontend/src/components/Character3D.vue @@ -2,7 +2,8 @@
+ @pointerup="finishPainting" @pointercancel="finishPainting" @pointerleave="brushCursor.visible = false">
+
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; diff --git a/frontend/src/components/falukant/CharacterAppearanceEditor.vue b/frontend/src/components/falukant/CharacterAppearanceEditor.vue index 30e3e1b..2a7008d 100644 --- a/frontend/src/components/falukant/CharacterAppearanceEditor.vue +++ b/frontend/src/components/falukant/CharacterAppearanceEditor.vue @@ -28,9 +28,10 @@

{{ toolHint }}

+
Farben ausprobieren @@ -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.'; }, diff --git a/frontend/src/utils/characterAppearance.js b/frontend/src/utils/characterAppearance.js index bb06c0d..e0f4836 100644 --- a/frontend/src/utils/characterAppearance.js +++ b/frontend/src/utils/characterAppearance.js @@ -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 ', '#include \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 ', ` #include +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 ', ` #include 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 valid = typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color); - state.enabled.value = valid ? 1 : 0; - if (valid) state.target.value.set(color); + 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.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); - 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; + 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 (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(); }