Ersetze grobe 3D-Markierungen durch präzise UV-Farbmasken
All checks were successful
Deploy to production / deploy (push) Successful in 2m33s
All checks were successful
Deploy to production / deploy (push) Successful in 2m33s
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user