mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
2047 lines
55 KiB
TypeScript
2047 lines
55 KiB
TypeScript
/*
|
|
* Portions of this file are adapted from Ears, Ears2, and the Ears Skin Manipulator:
|
|
* https://git.sleeping.town/exa.mods/Ears
|
|
* https://git.sleeping.town/exa.mods/Ears2
|
|
* Source revisions:
|
|
* - Ears: 5d050d461d207c1a7cda0de11e2562f6581f7a16
|
|
* - Ears2: 8ad9adc213e3b16b1a3306fb4141cfb618c25a6f
|
|
*
|
|
* MIT License
|
|
*
|
|
* Copyright (c) 2020-2026 Exa Skye and contributors
|
|
* Copyright (c) 2021-2026 Exa Skye and contributors
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
* of this software and associated documentation files (the "Software"), to deal
|
|
* in the Software without restriction, including without limitation the rights
|
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the Software is
|
|
* furnished to do so, subject to the following conditions:
|
|
*
|
|
* The above copyright notices and this permission notice shall be included in
|
|
* all copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
* SOFTWARE.
|
|
*/
|
|
|
|
import * as THREE from 'three'
|
|
|
|
type EarMode =
|
|
| 'NONE'
|
|
| 'ABOVE'
|
|
| 'SIDES'
|
|
| 'BEHIND'
|
|
| 'AROUND'
|
|
| 'FLOPPY'
|
|
| 'CROSS'
|
|
| 'OUT'
|
|
| 'TALL'
|
|
| 'TALL_CROSS'
|
|
type EarAnchor = 'CENTER' | 'FRONT' | 'BACK'
|
|
type TailMode =
|
|
| 'NONE'
|
|
| 'DOWN'
|
|
| 'BACK'
|
|
| 'UP'
|
|
| 'VERTICAL'
|
|
| 'CROSS'
|
|
| 'CROSS_OVERLAP'
|
|
| 'STAR'
|
|
| 'STAR_OVERLAP'
|
|
type WingMode =
|
|
| 'NONE'
|
|
| 'SYMMETRIC_DUAL'
|
|
| 'SYMMETRIC_SINGLE'
|
|
| 'ASYMMETRIC_L'
|
|
| 'ASYMMETRIC_R'
|
|
| 'ASYMMETRIC_DUAL'
|
|
| 'FLAT'
|
|
type WingAnimationMode = 'NORMAL' | 'NONE' | 'NO_FLIGHT'
|
|
type LegMode = 'PLANTIGRADE' | 'DIGITIGRADE_PARTIAL' | 'DIGITIGRADE_FULL'
|
|
type Protrusions =
|
|
| 'NONE'
|
|
| 'HORN'
|
|
| 'CLAWS'
|
|
| 'CLAWS_AND_HORN'
|
|
| 'HALO'
|
|
| 'DOUBLE_HALO'
|
|
| 'CLAWS_AND_HALO'
|
|
| 'CLAWS_AND_DOUBLE_HALO'
|
|
type BodyPart = 'HEAD' | 'TORSO' | 'LEFT_ARM' | 'RIGHT_ARM' | 'LEFT_LEG' | 'RIGHT_LEG'
|
|
type TextureSource = 'skin' | 'displaced-skin' | 'wing' | 'cape'
|
|
type TextureRotation = 'NONE' | 'CW' | 'CCW' | 'UPSIDE_DOWN'
|
|
type TextureFlip = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'BOTH'
|
|
|
|
interface EarsFeatures {
|
|
earMode: EarMode
|
|
earAnchor: EarAnchor
|
|
protrusions: Protrusions
|
|
tailMode: TailMode
|
|
tailSegments: number
|
|
tailBends: [number, number, number, number]
|
|
snoutOffset: number
|
|
snoutWidth: number
|
|
snoutHeight: number
|
|
snoutDepth: number
|
|
chestSize: number
|
|
wingMode: WingMode
|
|
wingAnimationMode: WingAnimationMode
|
|
capeEnabled: boolean
|
|
emissive: boolean
|
|
legMode: LegMode
|
|
animateTail: boolean
|
|
swapJacketBackAndTail: boolean
|
|
}
|
|
|
|
interface TexturePair {
|
|
base: THREE.Texture
|
|
emissive?: THREE.Texture
|
|
}
|
|
|
|
interface Anchor {
|
|
group: THREE.Group
|
|
origin: THREE.Vector3
|
|
}
|
|
|
|
interface RenderState {
|
|
anchor: Anchor | null
|
|
matrix: THREE.Matrix4
|
|
}
|
|
|
|
interface EarsModResources {
|
|
featureGroups: THREE.Group[]
|
|
featureMaterials: Set<THREE.Material>
|
|
featureTextures: Set<THREE.Texture>
|
|
emissiveOverlays: THREE.Mesh[]
|
|
pendingImages: HTMLImageElement[]
|
|
objectUrls: Set<string>
|
|
disposed: boolean
|
|
}
|
|
|
|
const EARS_REGISTRY_NAME = 'EarsModRegistry'
|
|
const EARS_FEATURE_PREFIX = 'EarsModFeature'
|
|
const EARS_OVERLAY_PREFIX = 'EarsModEmissiveOverlay'
|
|
const SKIN_SIZE = 64
|
|
const EARS_V0_MAGIC = 0x3f23d8
|
|
const EARS_V1_MAGIC = 0xea2501
|
|
const ALFALFA_MAGIC = 0xea1fa1fa
|
|
const UV_TEXEL_INSET = 1 / 32
|
|
|
|
const EAR_MODES: EarMode[] = [
|
|
'NONE',
|
|
'ABOVE',
|
|
'SIDES',
|
|
'BEHIND',
|
|
'AROUND',
|
|
'FLOPPY',
|
|
'CROSS',
|
|
'OUT',
|
|
'TALL',
|
|
'TALL_CROSS',
|
|
]
|
|
const EAR_ANCHORS: EarAnchor[] = ['CENTER', 'FRONT', 'BACK']
|
|
const TAIL_MODES: TailMode[] = [
|
|
'NONE',
|
|
'DOWN',
|
|
'BACK',
|
|
'UP',
|
|
'VERTICAL',
|
|
'CROSS',
|
|
'CROSS_OVERLAP',
|
|
'STAR',
|
|
'STAR_OVERLAP',
|
|
]
|
|
const WING_MODES: WingMode[] = [
|
|
'NONE',
|
|
'SYMMETRIC_DUAL',
|
|
'SYMMETRIC_SINGLE',
|
|
'ASYMMETRIC_L',
|
|
'ASYMMETRIC_R',
|
|
'ASYMMETRIC_DUAL',
|
|
'FLAT',
|
|
]
|
|
const WING_ANIMATION_MODES: WingAnimationMode[] = ['NORMAL', 'NONE', 'NO_FLIGHT']
|
|
const LEG_MODES: LegMode[] = ['PLANTIGRADE', 'DIGITIGRADE_PARTIAL', 'DIGITIGRADE_FULL']
|
|
const PROTRUSIONS: Protrusions[] = [
|
|
'NONE',
|
|
'HORN',
|
|
'CLAWS',
|
|
'CLAWS_AND_HORN',
|
|
'HALO',
|
|
'DOUBLE_HALO',
|
|
'CLAWS_AND_HALO',
|
|
'CLAWS_AND_DOUBLE_HALO',
|
|
]
|
|
const CLAW_PROTRUSIONS = new Set<Protrusions>([
|
|
'CLAWS',
|
|
'CLAWS_AND_HORN',
|
|
'CLAWS_AND_HALO',
|
|
'CLAWS_AND_DOUBLE_HALO',
|
|
])
|
|
|
|
const MAGIC_PIXELS = {
|
|
BLUE: 0x3f23d8,
|
|
GREEN: 0x23d848,
|
|
RED: 0xd82350,
|
|
PURPLE: 0xb923d8,
|
|
CYAN: 0x23d8c6,
|
|
ORANGE: 0xd87823,
|
|
PINK: 0xd823b7,
|
|
PURPLE2: 0xd823ff,
|
|
WHITE: 0xfefdf2,
|
|
GRAY: 0x5e605a,
|
|
} as const
|
|
|
|
const ALFALFA_REGIONS = [
|
|
[8, 0, 24, 8],
|
|
[0, 8, 8, 16],
|
|
[16, 8, 32, 16],
|
|
[4, 16, 12, 20],
|
|
[20, 16, 36, 20],
|
|
[44, 16, 52, 20],
|
|
[0, 20, 56, 32],
|
|
[20, 48, 28, 52],
|
|
[36, 48, 44, 52],
|
|
[16, 52, 48, 64],
|
|
] as const
|
|
|
|
const FORCED_OPAQUE_REGIONS = [
|
|
[8, 0, 24, 8],
|
|
[0, 8, 32, 16],
|
|
[4, 16, 12, 20],
|
|
[20, 16, 36, 20],
|
|
[44, 16, 52, 20],
|
|
[0, 20, 56, 32],
|
|
[20, 48, 28, 52],
|
|
[36, 48, 44, 52],
|
|
[16, 52, 48, 64],
|
|
] as const
|
|
|
|
const FORCED_OPAQUE_REGIONS_WITHOUT_LEG_BOTTOM = [
|
|
[8, 0, 24, 8],
|
|
[0, 8, 32, 16],
|
|
[4, 16, 8, 20],
|
|
[20, 16, 36, 20],
|
|
[44, 16, 52, 20],
|
|
[0, 20, 16, 38],
|
|
[16, 20, 56, 32],
|
|
[20, 48, 24, 52],
|
|
[36, 48, 44, 52],
|
|
[16, 52, 32, 58],
|
|
[32, 52, 48, 64],
|
|
] as const
|
|
|
|
const FORCED_OPAQUE_REGIONS_WITHOUT_LEGS = [
|
|
[8, 0, 24, 8],
|
|
[0, 8, 32, 16],
|
|
[4, 16, 8, 20],
|
|
[20, 16, 36, 20],
|
|
[44, 16, 52, 20],
|
|
[16, 20, 56, 32],
|
|
[20, 48, 24, 52],
|
|
[36, 48, 44, 52],
|
|
[32, 52, 48, 64],
|
|
] as const
|
|
|
|
const LEG_BOTTOM_HALF_REGIONS = [
|
|
[24, 48, 28, 52, true],
|
|
[16, 58, 32, 64, true],
|
|
[8, 16, 12, 20, true],
|
|
[0, 26, 16, 32, true],
|
|
[8, 32, 12, 36, false],
|
|
[0, 42, 16, 48, false],
|
|
[8, 48, 12, 52, false],
|
|
[0, 58, 16, 64, false],
|
|
] as const
|
|
|
|
const LEG_REGIONS = [
|
|
[20, 48, 28, 52, true],
|
|
[16, 52, 32, 64, true],
|
|
[4, 16, 12, 20, true],
|
|
[0, 20, 16, 32, true],
|
|
[4, 32, 12, 36, false],
|
|
[0, 36, 16, 48, false],
|
|
[4, 48, 12, 52, false],
|
|
[0, 52, 16, 64, false],
|
|
] as const
|
|
|
|
const BODY_PART_NODES: Record<BodyPart, string> = {
|
|
HEAD: 'Head',
|
|
TORSO: 'Body',
|
|
LEFT_ARM: 'Left_Arm',
|
|
RIGHT_ARM: 'Right_Arm',
|
|
LEFT_LEG: 'Left_Leg',
|
|
RIGHT_LEG: 'Right_Leg',
|
|
}
|
|
|
|
class BitReader {
|
|
private bitIndex = 0
|
|
|
|
constructor(private readonly data: Uint8Array) {}
|
|
|
|
get remainingBits() {
|
|
return this.data.length * 8 - this.bitIndex
|
|
}
|
|
|
|
read(bits: number) {
|
|
if (this.remainingBits < bits) {
|
|
throw new Error('Unexpected end of Ears data')
|
|
}
|
|
|
|
let result = 0
|
|
for (let index = 0; index < bits; index++) {
|
|
const byteIndex = Math.floor(this.bitIndex / 8)
|
|
const shift = 7 - (this.bitIndex % 8)
|
|
result = (result << 1) | ((this.data[byteIndex] >> shift) & 1)
|
|
this.bitIndex++
|
|
}
|
|
return result
|
|
}
|
|
|
|
readBoolean() {
|
|
return this.read(1) === 1
|
|
}
|
|
|
|
readUnit(bits: number) {
|
|
return this.read(bits) / ((1 << bits) - 1)
|
|
}
|
|
|
|
readSignedUnit(bits: number) {
|
|
const negative = this.readBoolean()
|
|
const value = this.read(bits) / ((1 << bits) - 1)
|
|
return negative ? -value : value
|
|
}
|
|
}
|
|
|
|
function pixelOffset(x: number, y: number) {
|
|
return (y * SKIN_SIZE + x) * 4
|
|
}
|
|
|
|
function getPixel(data: Uint8ClampedArray, x: number, y: number) {
|
|
const offset = pixelOffset(x, y)
|
|
const r = data[offset]
|
|
const g = data[offset + 1]
|
|
const b = data[offset + 2]
|
|
const a = data[offset + 3]
|
|
return {
|
|
r,
|
|
g,
|
|
b,
|
|
a,
|
|
rgb: (r << 16) | (g << 8) | b,
|
|
}
|
|
}
|
|
|
|
function getConfigPixel(data: Uint8ClampedArray, index: number) {
|
|
return getPixel(data, index % 4, 32 + Math.floor(index / 4))
|
|
}
|
|
|
|
function getImageDimensions(image: CanvasImageSource) {
|
|
if (image instanceof HTMLImageElement) {
|
|
return [image.naturalWidth, image.naturalHeight] as const
|
|
}
|
|
if (image instanceof HTMLVideoElement) {
|
|
return [image.videoWidth, image.videoHeight] as const
|
|
}
|
|
return [image.width, image.height] as const
|
|
}
|
|
|
|
function readSkinImage(texture: THREE.Texture) {
|
|
const image = texture.image as CanvasImageSource | undefined
|
|
if (!image) return null
|
|
|
|
const [width, height] = getImageDimensions(image)
|
|
if (width !== SKIN_SIZE || height !== SKIN_SIZE) return null
|
|
|
|
try {
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = SKIN_SIZE
|
|
canvas.height = SKIN_SIZE
|
|
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
if (!context) return null
|
|
|
|
context.drawImage(image, 0, 0, SKIN_SIZE, SKIN_SIZE)
|
|
return context.getImageData(0, 0, SKIN_SIZE, SKIN_SIZE)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
|
|
const bytes: number[] = []
|
|
for (let y = 0; y < 4; y++) {
|
|
for (let x = 0; x < 4; x++) {
|
|
if (x === 0 && y === 0) continue
|
|
const pixel = getPixel(data, x, 32 + y)
|
|
bytes.push(pixel.r, pixel.g, pixel.b)
|
|
}
|
|
}
|
|
|
|
try {
|
|
const reader = new BitReader(new Uint8Array(bytes))
|
|
const version = reader.read(8)
|
|
|
|
const ears = reader.read(6)
|
|
const earMode = ears === 0 ? 'NONE' : (EAR_MODES[Math.floor((ears - 1) / 3) + 1] ?? 'NONE')
|
|
const earAnchor = ears === 0 ? 'CENTER' : (EAR_ANCHORS[(ears - 1) % 3] ?? 'CENTER')
|
|
let protrusionsIndex = reader.read(2)
|
|
const tailIndex = reader.read(3)
|
|
let tailMode = TAIL_MODES[tailIndex] ?? 'NONE'
|
|
let tailSegments = 0
|
|
const tailBends: [number, number, number, number] = [0, 0, 0, 0]
|
|
|
|
if (tailIndex !== 0) {
|
|
tailSegments = reader.read(2) + 1
|
|
for (let index = 0; index < tailSegments; index++) {
|
|
tailBends[index] = reader.readSignedUnit(6) * 90
|
|
}
|
|
}
|
|
|
|
const snoutWidth = reader.read(3)
|
|
let snoutHeight = 0
|
|
let snoutDepth = 0
|
|
let snoutOffset = 0
|
|
if (snoutWidth > 0) {
|
|
snoutHeight = reader.read(2) + 1
|
|
snoutDepth = reader.read(3) + 1
|
|
snoutOffset = Math.min(reader.read(3), 8 - snoutHeight)
|
|
}
|
|
|
|
const chestSize = reader.readUnit(5)
|
|
const wingMode = WING_MODES[reader.read(3)] ?? 'NONE'
|
|
const animateWings = wingMode === 'NONE' ? false : reader.readBoolean()
|
|
const capeEnabled = reader.readBoolean()
|
|
const emissive = reader.readBoolean()
|
|
let wingAnimationMode: WingAnimationMode = animateWings ? 'NORMAL' : 'NONE'
|
|
let legMode: LegMode = 'PLANTIGRADE'
|
|
let animateTail = true
|
|
let swapJacketBackAndTail = false
|
|
|
|
if (version >= 1 && tailIndex === 7) {
|
|
tailMode = TAIL_MODES[tailIndex + reader.read(3)] ?? 'NONE'
|
|
}
|
|
if (version >= 2) {
|
|
legMode = LEG_MODES[reader.read(3)] ?? 'PLANTIGRADE'
|
|
if (wingMode !== 'NONE') {
|
|
wingAnimationMode = WING_ANIMATION_MODES[reader.read(3)] ?? wingAnimationMode
|
|
}
|
|
animateTail = reader.readBoolean()
|
|
swapJacketBackAndTail = reader.readBoolean()
|
|
}
|
|
if (version >= 3) {
|
|
protrusionsIndex |= reader.read(2) << 2
|
|
}
|
|
|
|
return {
|
|
earMode,
|
|
earAnchor,
|
|
protrusions: PROTRUSIONS[protrusionsIndex] ?? 'NONE',
|
|
tailMode,
|
|
tailSegments,
|
|
tailBends,
|
|
snoutOffset,
|
|
snoutWidth,
|
|
snoutHeight,
|
|
snoutDepth,
|
|
chestSize,
|
|
wingMode,
|
|
wingAnimationMode,
|
|
capeEnabled,
|
|
emissive,
|
|
legMode,
|
|
animateTail,
|
|
swapJacketBackAndTail,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function pixelValueToUnit(value: number) {
|
|
if (value === 0) return 0
|
|
let shifted = value - 128
|
|
shifted += shifted < 0 ? -1 : 1
|
|
return shifted / 128
|
|
}
|
|
|
|
function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
|
|
const earModeByPixel = new Map<number, EarMode>([
|
|
[MAGIC_PIXELS.RED, 'NONE'],
|
|
[MAGIC_PIXELS.BLUE, 'ABOVE'],
|
|
[MAGIC_PIXELS.GREEN, 'SIDES'],
|
|
[MAGIC_PIXELS.PURPLE, 'BEHIND'],
|
|
[MAGIC_PIXELS.CYAN, 'AROUND'],
|
|
[MAGIC_PIXELS.ORANGE, 'FLOPPY'],
|
|
[MAGIC_PIXELS.PINK, 'CROSS'],
|
|
[MAGIC_PIXELS.PURPLE2, 'OUT'],
|
|
[MAGIC_PIXELS.WHITE, 'TALL'],
|
|
[MAGIC_PIXELS.GRAY, 'TALL_CROSS'],
|
|
])
|
|
const earAnchorByPixel = new Map<number, EarAnchor>([
|
|
[MAGIC_PIXELS.BLUE, 'CENTER'],
|
|
[MAGIC_PIXELS.GREEN, 'FRONT'],
|
|
[MAGIC_PIXELS.RED, 'BACK'],
|
|
])
|
|
const tailModeByPixel = new Map<number, TailMode>([
|
|
[MAGIC_PIXELS.RED, 'NONE'],
|
|
[MAGIC_PIXELS.BLUE, 'DOWN'],
|
|
[MAGIC_PIXELS.GREEN, 'BACK'],
|
|
[MAGIC_PIXELS.PURPLE, 'UP'],
|
|
[MAGIC_PIXELS.ORANGE, 'VERTICAL'],
|
|
[MAGIC_PIXELS.PINK, 'CROSS'],
|
|
[MAGIC_PIXELS.PURPLE2, 'CROSS_OVERLAP'],
|
|
[MAGIC_PIXELS.WHITE, 'STAR'],
|
|
[MAGIC_PIXELS.GRAY, 'STAR_OVERLAP'],
|
|
])
|
|
const wingModeByPixel = new Map<number, WingMode>([
|
|
[MAGIC_PIXELS.BLUE, 'NONE'],
|
|
[MAGIC_PIXELS.RED, 'NONE'],
|
|
[MAGIC_PIXELS.PINK, 'SYMMETRIC_DUAL'],
|
|
[MAGIC_PIXELS.GREEN, 'SYMMETRIC_SINGLE'],
|
|
[MAGIC_PIXELS.CYAN, 'ASYMMETRIC_L'],
|
|
[MAGIC_PIXELS.ORANGE, 'ASYMMETRIC_R'],
|
|
[MAGIC_PIXELS.PURPLE, 'ASYMMETRIC_DUAL'],
|
|
[MAGIC_PIXELS.PURPLE2, 'FLAT'],
|
|
])
|
|
const protrusionsByPixel = new Map<number, Protrusions>([
|
|
[MAGIC_PIXELS.BLUE, 'NONE'],
|
|
[MAGIC_PIXELS.RED, 'NONE'],
|
|
[MAGIC_PIXELS.GREEN, 'CLAWS'],
|
|
[MAGIC_PIXELS.PURPLE, 'HORN'],
|
|
[MAGIC_PIXELS.CYAN, 'CLAWS_AND_HORN'],
|
|
[MAGIC_PIXELS.WHITE, 'HALO'],
|
|
[MAGIC_PIXELS.GRAY, 'DOUBLE_HALO'],
|
|
[MAGIC_PIXELS.PURPLE2, 'CLAWS_AND_HALO'],
|
|
[MAGIC_PIXELS.PINK, 'CLAWS_AND_DOUBLE_HALO'],
|
|
])
|
|
const wingAnimationModeByPixel = new Map<number, WingAnimationMode>([
|
|
[MAGIC_PIXELS.BLUE, 'NORMAL'],
|
|
[MAGIC_PIXELS.RED, 'NONE'],
|
|
[MAGIC_PIXELS.GREEN, 'NO_FLIGHT'],
|
|
])
|
|
const legModeByPixel = new Map<number, LegMode>([
|
|
[MAGIC_PIXELS.BLUE, 'PLANTIGRADE'],
|
|
[MAGIC_PIXELS.GREEN, 'DIGITIGRADE_PARTIAL'],
|
|
[MAGIC_PIXELS.PINK, 'DIGITIGRADE_FULL'],
|
|
])
|
|
|
|
const earMode = earModeByPixel.get(getConfigPixel(data, 1).rgb) ?? 'NONE'
|
|
const earAnchor = earAnchorByPixel.get(getConfigPixel(data, 2).rgb) ?? 'CENTER'
|
|
const protrusions = protrusionsByPixel.get(getConfigPixel(data, 3).rgb) ?? 'NONE'
|
|
const tailMode = tailModeByPixel.get(getConfigPixel(data, 4).rgb) ?? 'NONE'
|
|
const tailPixel = getConfigPixel(data, 5)
|
|
const tailBends: [number, number, number, number] = [0, 0, 0, 0]
|
|
let tailSegments = 0
|
|
|
|
if (tailPixel.rgb !== MAGIC_PIXELS.BLUE) {
|
|
tailBends[0] = pixelValueToUnit(255 - tailPixel.a) * 90
|
|
tailBends[1] = pixelValueToUnit(tailPixel.r) * 90
|
|
tailBends[2] = pixelValueToUnit(tailPixel.g) * 90
|
|
tailBends[3] = pixelValueToUnit(tailPixel.b) * 90
|
|
tailSegments = 1
|
|
if (tailBends[1] !== 0) tailSegments = 2
|
|
if (tailBends[2] !== 0) tailSegments = 3
|
|
if (tailBends[3] !== 0) tailSegments = 4
|
|
}
|
|
|
|
const snoutPixel = getConfigPixel(data, 6)
|
|
const etcPixel = getConfigPixel(data, 7)
|
|
let snoutWidth = 0
|
|
let snoutHeight = 0
|
|
let snoutDepth = 0
|
|
let snoutOffset = 0
|
|
if (snoutPixel.rgb !== MAGIC_PIXELS.BLUE) {
|
|
snoutWidth = Math.min(snoutPixel.r, 7)
|
|
snoutHeight = Math.min(snoutPixel.g, 4)
|
|
snoutDepth = Math.min(snoutPixel.b, 8)
|
|
snoutOffset = Math.min(etcPixel.g, 8 - snoutHeight)
|
|
}
|
|
|
|
const etcIsDefault = etcPixel.rgb === MAGIC_PIXELS.BLUE
|
|
const chestSize = etcIsDefault ? 0 : Math.min(etcPixel.r / 128, 1)
|
|
const capeEnabled = !etcIsDefault && (etcPixel.b & 16) !== 0
|
|
const bitflags = getConfigPixel(data, 12).rgb
|
|
const bitflagsAreDefault = bitflags === MAGIC_PIXELS.BLUE
|
|
|
|
return {
|
|
earMode,
|
|
earAnchor,
|
|
protrusions,
|
|
tailMode,
|
|
tailSegments,
|
|
tailBends,
|
|
snoutOffset,
|
|
snoutWidth,
|
|
snoutHeight,
|
|
snoutDepth,
|
|
chestSize,
|
|
wingMode: wingModeByPixel.get(getConfigPixel(data, 8).rgb) ?? 'NONE',
|
|
wingAnimationMode: wingAnimationModeByPixel.get(getConfigPixel(data, 9).rgb) ?? 'NORMAL',
|
|
capeEnabled,
|
|
emissive: getConfigPixel(data, 10).rgb === MAGIC_PIXELS.ORANGE,
|
|
legMode: legModeByPixel.get(getConfigPixel(data, 11).rgb) ?? 'PLANTIGRADE',
|
|
animateTail: bitflagsAreDefault || (bitflags & 1) !== 0,
|
|
swapJacketBackAndTail: !bitflagsAreDefault && (bitflags & 2) !== 0,
|
|
}
|
|
}
|
|
|
|
function parseFeatures(data: Uint8ClampedArray) {
|
|
const magic = getPixel(data, 0, 32).rgb
|
|
if (magic === EARS_V0_MAGIC) return parseV0Features(data)
|
|
if (magic === EARS_V1_MAGIC) return parseV1Features(data)
|
|
return null
|
|
}
|
|
|
|
function parseAlfalfa(data: Uint8ClampedArray) {
|
|
let packed = 0n
|
|
let index = 0n
|
|
|
|
for (const [x1, y1, x2, y2] of ALFALFA_REGIONS) {
|
|
for (let x = x1; x < x2; x++) {
|
|
for (let y = y1; y < y2; y++) {
|
|
const alpha = data[pixelOffset(x, y) + 3]
|
|
if (alpha === 0) continue
|
|
const value = 0x7f - (alpha & 0x7f)
|
|
packed |= BigInt(value) << (index * 7n)
|
|
index++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (packed === 0n) return new Map<string, Uint8Array>()
|
|
|
|
let hex = packed.toString(16)
|
|
if (hex.length % 2 !== 0) hex = `0${hex}`
|
|
const bytes = new Uint8Array(hex.length / 2 + 1)
|
|
for (let byteIndex = 0; byteIndex < hex.length / 2; byteIndex++) {
|
|
bytes[byteIndex + 1] = Number.parseInt(hex.slice(byteIndex * 2, byteIndex * 2 + 2), 16)
|
|
}
|
|
|
|
let offset = 1
|
|
const readByte = () => {
|
|
if (offset >= bytes.length) throw new Error('Unexpected end of Alfalfa data')
|
|
return bytes[offset++]
|
|
}
|
|
|
|
try {
|
|
const magic = ((readByte() << 24) | (readByte() << 16) | (readByte() << 8) | readByte()) >>> 0
|
|
if (magic !== ALFALFA_MAGIC || readByte() !== 1) {
|
|
return new Map<string, Uint8Array>()
|
|
}
|
|
|
|
const entries = new Map<string, Uint8Array>()
|
|
const predefinedKeys = ['END', 'wing', 'erase', 'cape']
|
|
while (offset < bytes.length) {
|
|
const first = readByte()
|
|
let key: string
|
|
if (first < 64) {
|
|
key = predefinedKeys[first] ?? `!unk${first}`
|
|
} else {
|
|
const chars = [first]
|
|
while ((chars[chars.length - 1] & 0x80) === 0) {
|
|
chars.push(readByte())
|
|
}
|
|
chars[chars.length - 1] &= 0x7f
|
|
key = String.fromCharCode(...chars)
|
|
}
|
|
if (key === 'END') break
|
|
|
|
const value: number[] = []
|
|
let length: number
|
|
do {
|
|
length = readByte()
|
|
for (let valueIndex = 0; valueIndex < length; valueIndex++) {
|
|
value.push(readByte())
|
|
}
|
|
} while (length === 255)
|
|
entries.set(key, new Uint8Array(value))
|
|
}
|
|
return entries
|
|
} catch {
|
|
return new Map<string, Uint8Array>()
|
|
}
|
|
}
|
|
|
|
function applyEraseData(data: Uint8ClampedArray, eraseData: Uint8Array | undefined) {
|
|
if (!eraseData) return
|
|
|
|
const reader = new BitReader(eraseData)
|
|
try {
|
|
while (reader.remainingBits >= 22) {
|
|
const x = reader.read(6)
|
|
const y = reader.read(6)
|
|
const width = reader.read(5) + 1
|
|
const height = reader.read(5) + 1
|
|
for (let pixelX = x; pixelX < Math.min(x + width, SKIN_SIZE); pixelX++) {
|
|
for (let pixelY = y; pixelY < Math.min(y + height, SKIN_SIZE); pixelY++) {
|
|
const offset = pixelOffset(pixelX, pixelY)
|
|
data[offset] = 0
|
|
data[offset + 1] = 0
|
|
data[offset + 2] = 0
|
|
data[offset + 3] = 0
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
function forceOpaqueSkinRegions(data: Uint8ClampedArray, legMode: LegMode) {
|
|
const regions =
|
|
legMode === 'DIGITIGRADE_FULL'
|
|
? FORCED_OPAQUE_REGIONS_WITHOUT_LEGS
|
|
: legMode === 'DIGITIGRADE_PARTIAL'
|
|
? FORCED_OPAQUE_REGIONS_WITHOUT_LEG_BOTTOM
|
|
: FORCED_OPAQUE_REGIONS
|
|
for (const [x1, y1, x2, y2] of regions) {
|
|
for (let x = x1; x < x2; x++) {
|
|
for (let y = y1; y < y2; y++) {
|
|
data[pixelOffset(x, y) + 3] = 255
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function displaceDigitigradeLegPixels(data: Uint8ClampedArray, legMode: LegMode) {
|
|
if (legMode === 'PLANTIGRADE') return null
|
|
|
|
const displaced = new Uint8ClampedArray(data.length)
|
|
const regions = legMode === 'DIGITIGRADE_FULL' ? LEG_REGIONS : LEG_BOTTOM_HALF_REGIONS
|
|
for (const [x1, y1, x2, y2, forceOpaque] of regions) {
|
|
for (let x = x1; x < x2; x++) {
|
|
for (let y = y1; y < y2; y++) {
|
|
const offset = pixelOffset(x, y)
|
|
displaced.set(data.subarray(offset, offset + 4), offset)
|
|
if (forceOpaque) displaced[offset + 3] = 255
|
|
data.fill(0, offset, offset + 4)
|
|
}
|
|
}
|
|
}
|
|
return displaced
|
|
}
|
|
|
|
function swapJacketBackAndTailPixels(data: Uint8ClampedArray) {
|
|
const tail = new Uint8ClampedArray(8 * 12 * 4)
|
|
for (let x = 0; x < 8; x++) {
|
|
for (let y = 0; y < 12; y++) {
|
|
const tailOffset = pixelOffset(56 + x, 16 + y)
|
|
const savedOffset = (y * 8 + x) * 4
|
|
tail.set(data.subarray(tailOffset, tailOffset + 4), savedOffset)
|
|
|
|
const jacketOffset = pixelOffset(32 + x, 36 + (11 - y))
|
|
data.set(data.subarray(jacketOffset, jacketOffset + 4), tailOffset)
|
|
}
|
|
}
|
|
for (let x = 0; x < 8; x++) {
|
|
for (let y = 0; y < 12; y++) {
|
|
const savedOffset = (y * 8 + x) * 4
|
|
data.set(tail.subarray(savedOffset, savedOffset + 4), pixelOffset(32 + x, 36 + y))
|
|
}
|
|
}
|
|
}
|
|
|
|
function getEmissivePalette(data: Uint8ClampedArray) {
|
|
const palette = new Set<number>()
|
|
for (let x = 52; x < 56; x++) {
|
|
for (let y = 32; y < 36; y++) {
|
|
const pixel = getPixel(data, x, y)
|
|
if (pixel.a > 0) palette.add(pixel.rgb)
|
|
}
|
|
}
|
|
return palette
|
|
}
|
|
|
|
function splitEmissivePixels(data: Uint8ClampedArray, palette: Set<number>) {
|
|
const base = new Uint8ClampedArray(data)
|
|
const emissive = new Uint8ClampedArray(data)
|
|
|
|
for (let offset = 0; offset < data.length; offset += 4) {
|
|
const rgb = (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2]
|
|
if (palette.has(rgb)) {
|
|
base[offset + 3] = 0
|
|
} else {
|
|
emissive[offset + 3] = 0
|
|
}
|
|
}
|
|
|
|
return { base, emissive }
|
|
}
|
|
|
|
function createCanvasTexture(data: Uint8ClampedArray, width: number, height: number) {
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = width
|
|
canvas.height = height
|
|
const context = canvas.getContext('2d')
|
|
if (!context) throw new Error('Could not create Ears texture canvas')
|
|
context.putImageData(new ImageData(data, width, height), 0, 0)
|
|
|
|
const texture = new THREE.CanvasTexture(canvas)
|
|
texture.colorSpace = THREE.SRGBColorSpace
|
|
texture.flipY = false
|
|
texture.magFilter = THREE.NearestFilter
|
|
texture.minFilter = THREE.NearestFilter
|
|
texture.generateMipmaps = false
|
|
return texture
|
|
}
|
|
|
|
function readPngDimensions(data: Uint8Array) {
|
|
if (
|
|
data.length < 24 ||
|
|
data[0] !== 0x89 ||
|
|
data[1] !== 0x50 ||
|
|
data[2] !== 0x4e ||
|
|
data[3] !== 0x47
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
return [view.getUint32(16), view.getUint32(20)] as const
|
|
}
|
|
|
|
function createEmbeddedTexture(
|
|
data: Uint8Array,
|
|
targetWidth: number,
|
|
targetHeight: number,
|
|
resources: EarsModResources,
|
|
palette?: Set<number>,
|
|
legacyWing = false,
|
|
): TexturePair {
|
|
const baseCanvas = document.createElement('canvas')
|
|
baseCanvas.width = targetWidth
|
|
baseCanvas.height = targetHeight
|
|
const baseTexture = new THREE.CanvasTexture(baseCanvas)
|
|
baseTexture.colorSpace = THREE.SRGBColorSpace
|
|
baseTexture.flipY = false
|
|
baseTexture.magFilter = THREE.NearestFilter
|
|
baseTexture.minFilter = THREE.NearestFilter
|
|
baseTexture.generateMipmaps = false
|
|
resources.featureTextures.add(baseTexture)
|
|
|
|
let emissiveCanvas: HTMLCanvasElement | undefined
|
|
let emissiveTexture: THREE.CanvasTexture | undefined
|
|
if (palette?.size) {
|
|
emissiveCanvas = document.createElement('canvas')
|
|
emissiveCanvas.width = targetWidth
|
|
emissiveCanvas.height = targetHeight
|
|
emissiveTexture = new THREE.CanvasTexture(emissiveCanvas)
|
|
emissiveTexture.colorSpace = THREE.SRGBColorSpace
|
|
emissiveTexture.flipY = false
|
|
emissiveTexture.magFilter = THREE.NearestFilter
|
|
emissiveTexture.minFilter = THREE.NearestFilter
|
|
emissiveTexture.generateMipmaps = false
|
|
resources.featureTextures.add(emissiveTexture)
|
|
}
|
|
|
|
const blob = new Blob([data as BlobPart], { type: 'image/png' })
|
|
const objectUrl = URL.createObjectURL(blob)
|
|
const image = new Image()
|
|
resources.objectUrls.add(objectUrl)
|
|
resources.pendingImages.push(image)
|
|
|
|
image.onload = () => {
|
|
resources.objectUrls.delete(objectUrl)
|
|
URL.revokeObjectURL(objectUrl)
|
|
if (resources.disposed) return
|
|
|
|
const rawCanvas = document.createElement('canvas')
|
|
rawCanvas.width = targetWidth
|
|
rawCanvas.height = targetHeight
|
|
const rawContext = rawCanvas.getContext('2d', { willReadFrequently: true })
|
|
const baseContext = baseCanvas.getContext('2d')
|
|
if (!rawContext || !baseContext) return
|
|
|
|
rawContext.clearRect(0, 0, targetWidth, targetHeight)
|
|
rawContext.drawImage(image, 0, legacyWing ? 2 : 0)
|
|
if (!palette?.size || !emissiveCanvas || !emissiveTexture) {
|
|
baseContext.drawImage(rawCanvas, 0, 0)
|
|
baseTexture.needsUpdate = true
|
|
return
|
|
}
|
|
|
|
const rawData = rawContext.getImageData(0, 0, targetWidth, targetHeight)
|
|
const split = splitEmissivePixels(rawData.data, palette)
|
|
baseContext.putImageData(new ImageData(split.base, targetWidth, targetHeight), 0, 0)
|
|
emissiveCanvas
|
|
.getContext('2d')
|
|
?.putImageData(new ImageData(split.emissive, targetWidth, targetHeight), 0, 0)
|
|
baseTexture.needsUpdate = true
|
|
emissiveTexture.needsUpdate = true
|
|
}
|
|
image.onerror = () => {
|
|
resources.objectUrls.delete(objectUrl)
|
|
URL.revokeObjectURL(objectUrl)
|
|
}
|
|
image.src = objectUrl
|
|
|
|
return {
|
|
base: baseTexture,
|
|
emissive: emissiveTexture,
|
|
}
|
|
}
|
|
|
|
function makeFeatureMaterial(texture: THREE.Texture, emissive: boolean) {
|
|
return new THREE.MeshStandardMaterial({
|
|
alphaTest: 0.1,
|
|
color: 0xffffff,
|
|
depthTest: true,
|
|
depthWrite: true,
|
|
emissive: emissive ? 0xffffff : 0x000000,
|
|
emissiveIntensity: emissive ? 1 : 0,
|
|
emissiveMap: emissive ? texture : null,
|
|
flatShading: true,
|
|
map: texture,
|
|
metalness: 0,
|
|
roughness: 1,
|
|
side: THREE.FrontSide,
|
|
toneMapped: false,
|
|
transparent: true,
|
|
})
|
|
}
|
|
|
|
function flipHorizontally(flip: TextureFlip): TextureFlip {
|
|
switch (flip) {
|
|
case 'NONE':
|
|
return 'HORIZONTAL'
|
|
case 'HORIZONTAL':
|
|
return 'NONE'
|
|
case 'VERTICAL':
|
|
return 'BOTH'
|
|
case 'BOTH':
|
|
return 'VERTICAL'
|
|
}
|
|
}
|
|
|
|
function calculateUvs(
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
rotation: TextureRotation,
|
|
flip: TextureFlip,
|
|
textureWidth: number,
|
|
textureHeight: number,
|
|
) {
|
|
const transposed = rotation === 'CW' || rotation === 'CCW'
|
|
let minU = (u + UV_TEXEL_INSET) / textureWidth
|
|
let minV = (v + UV_TEXEL_INSET) / textureHeight
|
|
let maxU = (u + (transposed ? height : width) - UV_TEXEL_INSET) / textureWidth
|
|
let maxV = (v + (transposed ? width : height) - UV_TEXEL_INSET) / textureHeight
|
|
let resolvedFlip = flip
|
|
|
|
if (transposed) {
|
|
if (resolvedFlip === 'HORIZONTAL') resolvedFlip = 'VERTICAL'
|
|
else if (resolvedFlip === 'VERTICAL') resolvedFlip = 'HORIZONTAL'
|
|
}
|
|
if (resolvedFlip === 'HORIZONTAL' || resolvedFlip === 'BOTH') {
|
|
;[minU, maxU] = [maxU, minU]
|
|
}
|
|
if (resolvedFlip === 'VERTICAL' || resolvedFlip === 'BOTH') {
|
|
;[minV, maxV] = [maxV, minV]
|
|
}
|
|
|
|
const uvs = [
|
|
[minU, maxV],
|
|
[maxU, maxV],
|
|
[maxU, minV],
|
|
[minU, minV],
|
|
]
|
|
if (rotation === 'CW') {
|
|
uvs.unshift(uvs.pop()!)
|
|
} else if (rotation === 'CCW') {
|
|
uvs.push(uvs.shift()!)
|
|
} else if (rotation === 'UPSIDE_DOWN') {
|
|
uvs.push(uvs.shift()!, uvs.shift()!)
|
|
}
|
|
return uvs
|
|
}
|
|
|
|
class EarsGeometryBuilder {
|
|
private readonly materials = new Map<string, THREE.MeshStandardMaterial>()
|
|
private readonly stack: RenderState[] = []
|
|
private state: RenderState = {
|
|
anchor: null,
|
|
matrix: new THREE.Matrix4(),
|
|
}
|
|
private textureSource: TextureSource = 'skin'
|
|
|
|
constructor(
|
|
private readonly anchors: Map<BodyPart, Anchor>,
|
|
private readonly textures: Map<TextureSource, TexturePair>,
|
|
private readonly resources: EarsModResources,
|
|
) {}
|
|
|
|
push() {
|
|
this.stack.push({
|
|
anchor: this.state.anchor,
|
|
matrix: this.state.matrix.clone(),
|
|
})
|
|
}
|
|
|
|
pop() {
|
|
const state = this.stack.pop()
|
|
if (state) this.state = state
|
|
}
|
|
|
|
anchorTo(part: BodyPart) {
|
|
const anchor = this.anchors.get(part) ?? null
|
|
this.state.anchor = anchor
|
|
this.state.matrix.identity()
|
|
if (anchor) {
|
|
this.state.matrix.makeTranslation(anchor.origin.x, anchor.origin.y, anchor.origin.z)
|
|
}
|
|
}
|
|
|
|
bind(source: TextureSource) {
|
|
this.textureSource = source
|
|
}
|
|
|
|
translate(x: number, y: number, z: number) {
|
|
this.state.matrix.multiply(new THREE.Matrix4().makeTranslation(-x, -y, z))
|
|
}
|
|
|
|
rotate(angle: number, x: number, y: number, z: number) {
|
|
const axis = new THREE.Vector3(-x, -y, z).normalize()
|
|
this.state.matrix.multiply(
|
|
new THREE.Matrix4().makeRotationAxis(axis, THREE.MathUtils.degToRad(angle)),
|
|
)
|
|
}
|
|
|
|
scale(x: number, y: number, z: number) {
|
|
this.state.matrix.multiply(new THREE.Matrix4().makeScale(x, y, z))
|
|
}
|
|
|
|
front(
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
rotation: TextureRotation = 'NONE',
|
|
flip: TextureFlip = 'NONE',
|
|
grow = 0,
|
|
) {
|
|
this.quad(u, v, width, height, rotation, flip, grow, false, 0, 0, 0)
|
|
}
|
|
|
|
frontSkew(
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
xSkew: number,
|
|
ySkew: number,
|
|
zSkew: number,
|
|
rotation: TextureRotation = 'NONE',
|
|
flip: TextureFlip = 'NONE',
|
|
grow = 0,
|
|
) {
|
|
this.quad(u, v, width, height, rotation, flip, grow, false, xSkew, ySkew, zSkew)
|
|
}
|
|
|
|
back(
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
rotation: TextureRotation = 'NONE',
|
|
flip: TextureFlip = 'NONE',
|
|
grow = 0,
|
|
) {
|
|
this.quad(u, v, width, height, rotation, flipHorizontally(flip), grow, true, 0, 0, 0)
|
|
}
|
|
|
|
doubleSided(
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
rotation: TextureRotation = 'NONE',
|
|
flip: TextureFlip = 'NONE',
|
|
grow = 0,
|
|
) {
|
|
this.front(u, v, width, height, rotation, flip, grow)
|
|
this.back(u, v, width, height, rotation, flipHorizontally(flip), grow)
|
|
}
|
|
|
|
private getMaterial(texture: THREE.Texture, emissive: boolean) {
|
|
const key = `${texture.uuid}:${emissive}`
|
|
let material = this.materials.get(key)
|
|
if (!material) {
|
|
material = makeFeatureMaterial(texture, emissive)
|
|
this.materials.set(key, material)
|
|
this.resources.featureMaterials.add(material)
|
|
}
|
|
return material
|
|
}
|
|
|
|
private quad(
|
|
u: number,
|
|
v: number,
|
|
width: number,
|
|
height: number,
|
|
rotation: TextureRotation,
|
|
flip: TextureFlip,
|
|
grow: number,
|
|
back: boolean,
|
|
xSkew: number,
|
|
ySkew: number,
|
|
zSkew: number,
|
|
) {
|
|
const anchor = this.state.anchor
|
|
const textures = this.textures.get(this.textureSource)
|
|
if (!anchor || !textures) return
|
|
|
|
const skinTexture = this.textureSource === 'skin' || this.textureSource === 'displaced-skin'
|
|
const textureWidth = skinTexture ? 64 : 20
|
|
const textureHeight = skinTexture ? 64 : 16
|
|
const uvs = calculateUvs(u, v, width, height, rotation, flip, textureWidth, textureHeight)
|
|
const positions = back
|
|
? [
|
|
grow + xSkew,
|
|
grow + ySkew,
|
|
-zSkew,
|
|
-width - grow + xSkew,
|
|
grow,
|
|
-zSkew,
|
|
-width - grow - xSkew,
|
|
-height - grow,
|
|
zSkew,
|
|
grow - xSkew,
|
|
-height - grow + ySkew,
|
|
zSkew,
|
|
]
|
|
: [
|
|
grow - xSkew,
|
|
-height - grow + ySkew,
|
|
zSkew,
|
|
-width - grow - xSkew,
|
|
-height - grow - ySkew,
|
|
zSkew,
|
|
-width - grow + xSkew,
|
|
grow - ySkew,
|
|
-zSkew,
|
|
grow + xSkew,
|
|
grow + ySkew,
|
|
-zSkew,
|
|
]
|
|
const orderedUvs = back ? [uvs[3], uvs[2], uvs[1], uvs[0]] : [uvs[0], uvs[1], uvs[2], uvs[3]]
|
|
const geometry = new THREE.BufferGeometry()
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(orderedUvs.flat(), 2))
|
|
geometry.setIndex([0, 1, 2, 0, 2, 3])
|
|
geometry.computeVertexNormals()
|
|
|
|
const mesh = new THREE.Mesh(geometry, this.getMaterial(textures.base, false))
|
|
mesh.name = `${EARS_FEATURE_PREFIX}:${this.textureSource}`
|
|
mesh.matrix.copy(this.state.matrix)
|
|
mesh.matrixAutoUpdate = false
|
|
mesh.renderOrder = 0
|
|
anchor.group.add(mesh)
|
|
|
|
if (textures.emissive) {
|
|
const emissiveMesh = new THREE.Mesh(geometry, this.getMaterial(textures.emissive, true))
|
|
emissiveMesh.name = `${EARS_FEATURE_PREFIX}:emissive-${this.textureSource}`
|
|
emissiveMesh.matrix.copy(this.state.matrix)
|
|
emissiveMesh.matrixAutoUpdate = false
|
|
emissiveMesh.renderOrder = 1
|
|
anchor.group.add(emissiveMesh)
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyEarAnchor(builder: EarsGeometryBuilder, anchor: EarAnchor) {
|
|
if (anchor === 'CENTER') builder.translate(0, 0, 4)
|
|
else if (anchor === 'BACK') builder.translate(0, 0, 8)
|
|
}
|
|
|
|
function renderEars(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
const mode = features.earMode
|
|
if (mode === 'NONE') return
|
|
|
|
if (mode === 'ABOVE' || mode === 'AROUND') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
applyEarAnchor(builder, features.earAnchor)
|
|
builder.push()
|
|
builder.translate(-4, -16, 0)
|
|
builder.front(24, 0, 16, 8)
|
|
builder.back(56, 28, 16, 8, 'CW')
|
|
builder.pop()
|
|
if (mode === 'AROUND') {
|
|
builder.translate(-4, -8, 0)
|
|
builder.front(36, 16, 4, 8, 'CW')
|
|
builder.back(12, 16, 4, 8, 'CW')
|
|
builder.translate(12, 0, 0)
|
|
builder.front(36, 32, 4, 8, 'CW')
|
|
builder.back(12, 32, 4, 8, 'CW')
|
|
}
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
if (mode === 'SIDES') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
applyEarAnchor(builder, features.earAnchor)
|
|
builder.translate(-8, -8, 0)
|
|
builder.front(24, 0, 8, 8)
|
|
builder.back(56, 28, 8, 8, 'CW')
|
|
builder.translate(16, 0, 0)
|
|
builder.front(32, 0, 8, 8)
|
|
builder.back(56, 36, 8, 8, 'CW')
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
if (mode === 'BEHIND') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.translate(-16, -8, 0)
|
|
builder.front(24, 0, 8, 8)
|
|
builder.back(56, 28, 8, 8, 'CW')
|
|
builder.rotate(180, 0, 1, 0)
|
|
builder.translate(-8, 0, -8)
|
|
builder.front(32, 0, 8, 8)
|
|
builder.back(56, 36, 8, 8, 'CW')
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
if (mode === 'FLOPPY') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.translate(-8, -7, 0)
|
|
builder.rotate(-30, 1, 0, 0)
|
|
builder.front(24, 0, 8, 8)
|
|
builder.back(56, 28, 8, 8, 'CW')
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.rotate(-90, 0, 1, 0)
|
|
builder.translate(0, -7, -8)
|
|
builder.rotate(-30, 1, 0, 0)
|
|
builder.front(32, 0, 8, 8)
|
|
builder.back(56, 36, 8, 8, 'CW')
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
if (mode === 'CROSS') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
applyEarAnchor(builder, features.earAnchor)
|
|
builder.translate(4, -16, 0)
|
|
builder.push()
|
|
builder.rotate(45, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
builder.front(24, 0, 8, 8)
|
|
builder.back(56, 28, 8, 8, 'CW')
|
|
builder.pop()
|
|
builder.push()
|
|
builder.rotate(-45, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
builder.front(32, 0, 8, 8)
|
|
builder.back(56, 36, 8, 8, 'CW')
|
|
builder.pop()
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
if (mode === 'OUT') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.rotate(90, 0, 1, 0)
|
|
if (features.earAnchor === 'BACK') builder.translate(-16, -8, 0)
|
|
else if (features.earAnchor === 'CENTER') builder.translate(-8, -16, 0)
|
|
else builder.translate(0, -8, 0)
|
|
builder.front(24, 0, 8, 8)
|
|
builder.back(56, 28, 8, 8, 'CW')
|
|
builder.rotate(180, 0, 1, 0)
|
|
builder.translate(-8, 0, -8)
|
|
builder.front(32, 0, 8, 8)
|
|
builder.back(56, 36, 8, 8, 'CW')
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
if (mode === 'TALL') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.translate(0, -8, 0)
|
|
applyEarAnchor(builder, features.earAnchor)
|
|
const angle = -6
|
|
builder.rotate(angle / 3, 1, 0, 0)
|
|
builder.translate(0, -4, 0)
|
|
builder.front(24, 0, 8, 4, 'CW')
|
|
builder.back(56, 40, 8, 4)
|
|
builder.rotate(angle, 1, 0, 0)
|
|
builder.translate(0, -4, 0)
|
|
builder.front(28, 0, 8, 4, 'CW')
|
|
builder.back(56, 36, 8, 4)
|
|
builder.rotate(angle / 2, 1, 0, 0)
|
|
builder.translate(0, -4, 0)
|
|
builder.front(32, 0, 8, 4, 'CW')
|
|
builder.back(56, 32, 8, 4)
|
|
builder.rotate(angle, 1, 0, 0)
|
|
builder.translate(0, -4, 0)
|
|
builder.front(36, 0, 8, 4, 'CW')
|
|
builder.back(56, 28, 8, 4)
|
|
builder.pop()
|
|
return
|
|
}
|
|
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
applyEarAnchor(builder, features.earAnchor)
|
|
builder.translate(4, -24, 0)
|
|
builder.push()
|
|
builder.rotate(45, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
builder.front(24, 0, 8, 16, 'CW')
|
|
builder.back(56, 28, 8, 16)
|
|
builder.pop()
|
|
builder.push()
|
|
builder.rotate(-45, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
builder.front(24, 0, 8, 16, 'CW')
|
|
builder.back(56, 28, 8, 16)
|
|
builder.pop()
|
|
builder.pop()
|
|
}
|
|
|
|
function renderTail(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
const mode = features.tailMode
|
|
if (mode === 'NONE') return
|
|
|
|
let angle = 0
|
|
if (mode === 'DOWN') angle = 30
|
|
else if (
|
|
mode === 'BACK' ||
|
|
mode === 'CROSS' ||
|
|
mode === 'CROSS_OVERLAP' ||
|
|
mode === 'STAR' ||
|
|
mode === 'STAR_OVERLAP'
|
|
) {
|
|
angle = features.tailBends[0] !== 0 ? 90 : 80
|
|
} else if (mode === 'UP') {
|
|
angle = 130
|
|
}
|
|
|
|
const vertical = mode === 'VERTICAL'
|
|
const baseAngle = features.tailBends[0]
|
|
builder.push()
|
|
builder.anchorTo('TORSO')
|
|
builder.translate(0, -2, 4)
|
|
builder.rotate(angle, 1, 0, 0)
|
|
if (vertical) {
|
|
builder.translate(4, 0, 0)
|
|
builder.rotate(90, 0, 0, 1)
|
|
if (baseAngle < 0) {
|
|
builder.translate(4, 0, 0)
|
|
builder.rotate(baseAngle, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
}
|
|
builder.translate(-4, 0, 0)
|
|
if (baseAngle > 0) builder.rotate(baseAngle, 0, 1, 0)
|
|
builder.rotate(90, 1, 0, 0)
|
|
}
|
|
|
|
const segments = Math.max(features.tailSegments, 1)
|
|
const segmentHeight = Math.floor(12 / segments)
|
|
const angles = vertical
|
|
? [0, features.tailBends[1], features.tailBends[2], features.tailBends[3]]
|
|
: features.tailBends
|
|
|
|
for (let index = 0; index < segments; index++) {
|
|
const overlap = index === 0 || (mode !== 'CROSS_OVERLAP' && mode !== 'STAR_OVERLAP') ? 0 : 4
|
|
builder.rotate(angles[index], 1, 0, 0)
|
|
builder.doubleSided(56, 16 + index * segmentHeight, 8, segmentHeight, 'NONE', 'HORIZONTAL')
|
|
if (mode === 'CROSS' || mode === 'CROSS_OVERLAP') {
|
|
builder.push()
|
|
builder.translate(4, 0, 0)
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.translate(-4, -overlap, 0)
|
|
builder.doubleSided(
|
|
56,
|
|
16 + index * segmentHeight - overlap,
|
|
8,
|
|
segmentHeight + overlap,
|
|
'NONE',
|
|
'HORIZONTAL',
|
|
)
|
|
builder.pop()
|
|
} else if (mode === 'STAR' || mode === 'STAR_OVERLAP') {
|
|
for (let side = 0; side < 3; side++) {
|
|
builder.push()
|
|
builder.translate(4, 0, 0)
|
|
builder.rotate(45 * (side + 1), 0, 1, 0)
|
|
builder.translate(-4, -overlap, 0)
|
|
builder.doubleSided(
|
|
56,
|
|
16 + index * segmentHeight - overlap,
|
|
8,
|
|
segmentHeight + overlap,
|
|
'NONE',
|
|
'HORIZONTAL',
|
|
)
|
|
builder.pop()
|
|
}
|
|
}
|
|
builder.translate(0, segmentHeight, 0)
|
|
}
|
|
builder.pop()
|
|
}
|
|
|
|
function renderProtrusions(builder: EarsGeometryBuilder, features: EarsFeatures, slim: boolean) {
|
|
if (CLAW_PROTRUSIONS.has(features.protrusions)) {
|
|
const legOffset =
|
|
features.legMode === 'DIGITIGRADE_FULL'
|
|
? -1.5
|
|
: features.legMode === 'DIGITIGRADE_PARTIAL'
|
|
? -1
|
|
: 0
|
|
builder.push()
|
|
builder.anchorTo('LEFT_LEG')
|
|
builder.translate(0, 0, -4 + legOffset)
|
|
builder.rotate(90, 1, 0, 0)
|
|
builder.doubleSided(16, 48, 4, 4, 'NONE', 'HORIZONTAL')
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.anchorTo('RIGHT_LEG')
|
|
builder.translate(0, 0, -4 + legOffset)
|
|
builder.rotate(90, 1, 0, 0)
|
|
builder.doubleSided(0, 16, 4, 4, 'NONE', 'HORIZONTAL')
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.anchorTo('LEFT_ARM')
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.translate(-4, 0, slim ? 3 : 4)
|
|
builder.doubleSided(44, 48, 4, 4, 'UPSIDE_DOWN', 'HORIZONTAL')
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.anchorTo('RIGHT_ARM')
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
builder.doubleSided(52, 16, 4, 4, 'UPSIDE_DOWN')
|
|
builder.pop()
|
|
}
|
|
|
|
if (features.protrusions === 'HORN' || features.protrusions === 'CLAWS_AND_HORN') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.translate(0, -8, 0)
|
|
builder.rotate(25, 1, 0, 0)
|
|
builder.translate(0, -8, 0)
|
|
builder.doubleSided(56, 0, 8, 8)
|
|
builder.pop()
|
|
}
|
|
|
|
if (features.protrusions === 'HALO' || features.protrusions === 'CLAWS_AND_HALO') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.translate(0, -12, 0)
|
|
builder.rotate(90, 1, 0, 0)
|
|
builder.doubleSided(56, 0, 8, 8)
|
|
builder.pop()
|
|
}
|
|
|
|
if (features.protrusions === 'DOUBLE_HALO' || features.protrusions === 'CLAWS_AND_DOUBLE_HALO') {
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.translate(0, -10, 0)
|
|
builder.rotate(90, 1, 0, 0)
|
|
for (let index = 0; index < 2; index++) {
|
|
builder.translate(4, 4, 0)
|
|
builder.rotate(45, 0, 0, 1)
|
|
builder.translate(-4, -4, 2)
|
|
builder.doubleSided(56, 0, 8, 8)
|
|
}
|
|
builder.pop()
|
|
}
|
|
}
|
|
|
|
function drawDigitigradeLeg(
|
|
builder: EarsGeometryBuilder,
|
|
u: number,
|
|
v: number,
|
|
grow: number,
|
|
bottom: boolean,
|
|
mend: boolean,
|
|
) {
|
|
const width = 4
|
|
const height = 12
|
|
const depth = 4
|
|
const doubleGrow = grow * 2
|
|
const skew = 1
|
|
const vOffset = bottom ? 10 : 4
|
|
|
|
builder.push()
|
|
builder.translate(width / 2, height / 2, depth / 2)
|
|
builder.scale(
|
|
(width + doubleGrow) / width,
|
|
(height + doubleGrow) / height,
|
|
(depth + doubleGrow) / depth,
|
|
)
|
|
builder.translate(-width / 2, -height / 2, -depth / 2)
|
|
|
|
builder.push()
|
|
builder.translate(0, bottom ? -6 : -12, 0)
|
|
if (!mend && bottom) {
|
|
builder.push()
|
|
builder.translate(0, 2, 0)
|
|
builder.frontSkew(u + 4, v + vOffset + 2, 4, 4, 0, 0, -skew)
|
|
builder.pop()
|
|
} else {
|
|
builder.frontSkew(u + 4, v + vOffset, 4, 6, 0, 0, -skew)
|
|
}
|
|
if (mend && !bottom) {
|
|
builder.push()
|
|
builder.translate(0, 6, 0)
|
|
builder.frontSkew(u + 4, v + vOffset + 6, 4, 2, 0, 0, skew)
|
|
builder.pop()
|
|
}
|
|
|
|
builder.rotate(-90, 0, 1, 0)
|
|
builder.translate(0, 0, -4)
|
|
if (!mend && bottom) {
|
|
builder.push()
|
|
builder.translate(0, 2, 0)
|
|
builder.frontSkew(u + 8, v + vOffset + 2, 4, 4, -skew, 0, 0)
|
|
builder.pop()
|
|
} else {
|
|
builder.frontSkew(u + 8, v + vOffset, 4, 6, -skew, 0, 0)
|
|
}
|
|
if (mend && !bottom) {
|
|
builder.push()
|
|
builder.translate(0, 6, 0)
|
|
builder.frontSkew(u + 8, v + vOffset + 6, 4, 2, skew, 0, 0)
|
|
builder.pop()
|
|
}
|
|
|
|
builder.rotate(-90, 0, 1, 0)
|
|
builder.translate(0, 0, -4)
|
|
if (!mend && bottom) {
|
|
builder.push()
|
|
builder.translate(0, 2, 0)
|
|
builder.frontSkew(u + 12, v + vOffset + 2, 4, 4, 0, 0, skew)
|
|
builder.pop()
|
|
} else {
|
|
builder.frontSkew(u + 12, v + vOffset, 4, 6, 0, 0, skew)
|
|
}
|
|
if (mend && !bottom) {
|
|
builder.push()
|
|
builder.translate(0, 6, 0)
|
|
builder.frontSkew(u + 12, v + vOffset + 5, 4, 2, 0, 0, -skew)
|
|
builder.pop()
|
|
}
|
|
|
|
builder.rotate(-90, 0, 1, 0)
|
|
builder.translate(0, 0, -4)
|
|
if (!mend && bottom) {
|
|
builder.push()
|
|
builder.translate(0, 2, 0)
|
|
builder.frontSkew(u, v + vOffset + 2, 4, 4, skew, 0, 0)
|
|
builder.pop()
|
|
} else {
|
|
builder.frontSkew(u, v + vOffset, 4, 6, skew, 0, 0)
|
|
}
|
|
if (mend && !bottom) {
|
|
builder.push()
|
|
builder.translate(0, 6, 0)
|
|
builder.frontSkew(u, v + vOffset + 6, 4, 2, -skew, 0, 0)
|
|
builder.pop()
|
|
}
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.rotate(90, 1, 0, 0)
|
|
if (bottom) {
|
|
builder.push()
|
|
builder.translate(0, -skew, 0)
|
|
builder.front(u + 8, v, 4, 4, 'NONE', 'VERTICAL')
|
|
builder.pop()
|
|
} else {
|
|
builder.push()
|
|
builder.translate(0, skew, 12)
|
|
builder.back(u + 8, v, 4, 4, 'NONE', 'VERTICAL')
|
|
builder.pop()
|
|
}
|
|
if (mend && bottom) {
|
|
builder.translate(0, 0, 6)
|
|
builder.front(u + 4, v + vOffset, 4, 1)
|
|
builder.translate(0, 4, 0)
|
|
builder.back(u + 12, v + vOffset, 4, 1)
|
|
}
|
|
builder.pop()
|
|
builder.pop()
|
|
}
|
|
|
|
function renderDigitigradeLegs(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
if (features.legMode === 'PLANTIGRADE') return
|
|
|
|
const full = features.legMode === 'DIGITIGRADE_FULL'
|
|
builder.bind('displaced-skin')
|
|
for (let layer = 0; layer < 2; layer++) {
|
|
const grow = layer === 0 ? 0 : 0.25
|
|
|
|
builder.push()
|
|
builder.anchorTo('LEFT_LEG')
|
|
if (full) builder.translate(0, 0, -0.5)
|
|
if (layer === 1) builder.translate(0, 0.5, 0)
|
|
if (full) drawDigitigradeLeg(builder, layer === 0 ? 16 : 0, 48, grow, false, true)
|
|
drawDigitigradeLeg(builder, layer === 0 ? 16 : 0, 48, grow, true, !full)
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.anchorTo('RIGHT_LEG')
|
|
if (full) builder.translate(0, 0, -0.5)
|
|
if (layer === 1) builder.translate(0, 0.5, 0)
|
|
if (full) drawDigitigradeLeg(builder, 0, layer === 0 ? 16 : 32, grow, false, true)
|
|
drawDigitigradeLeg(builder, 0, layer === 0 ? 16 : 32, grow, true, !full)
|
|
builder.pop()
|
|
}
|
|
builder.bind('skin')
|
|
}
|
|
|
|
function renderSnout(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
const { snoutOffset, snoutWidth, snoutHeight, snoutDepth } = features
|
|
if (snoutWidth <= 0 || snoutHeight <= 0 || snoutDepth <= 0) return
|
|
|
|
builder.push()
|
|
builder.anchorTo('HEAD')
|
|
builder.translate((8 - snoutWidth) / 2, -(snoutOffset + snoutHeight), -snoutDepth)
|
|
builder.doubleSided(0, 2, snoutWidth, snoutHeight)
|
|
|
|
builder.push()
|
|
builder.rotate(-90, 1, 0, 0)
|
|
builder.translate(0, -1, 0)
|
|
builder.doubleSided(0, 1, snoutWidth, 1)
|
|
for (let index = 0; index < snoutDepth - 1; index++) {
|
|
builder.translate(0, -1, 0)
|
|
builder.doubleSided(0, 0, snoutWidth, 1)
|
|
}
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.translate(0, snoutHeight, 0)
|
|
builder.rotate(90, 1, 0, 0)
|
|
builder.doubleSided(0, 2 + snoutHeight, snoutWidth, 1)
|
|
for (let index = 0; index < snoutDepth - 1; index++) {
|
|
builder.translate(0, 1, 0)
|
|
builder.doubleSided(0, 3 + snoutHeight, snoutWidth, 1)
|
|
}
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.push()
|
|
builder.translate(-1, 0, 0)
|
|
builder.doubleSided(7, 0, 1, snoutHeight)
|
|
for (let index = 0; index < snoutDepth - 1; index++) {
|
|
builder.translate(-1, 0, 0)
|
|
builder.doubleSided(7, 4, 1, snoutHeight)
|
|
}
|
|
builder.pop()
|
|
builder.push()
|
|
builder.translate(-1, 0, snoutWidth)
|
|
builder.doubleSided(7, 0, 1, snoutHeight)
|
|
for (let index = 0; index < snoutDepth - 1; index++) {
|
|
builder.translate(-1, 0, 0)
|
|
builder.doubleSided(7, 4, 1, snoutHeight)
|
|
}
|
|
builder.pop()
|
|
builder.pop()
|
|
builder.pop()
|
|
}
|
|
|
|
function renderChest(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
if (features.chestSize <= 0) return
|
|
|
|
for (const layer of [0, 1]) {
|
|
builder.push()
|
|
builder.anchorTo('TORSO')
|
|
builder.translate(0, -10, 0)
|
|
builder.rotate(-features.chestSize * 45, 1, 0, 0)
|
|
if (layer === 0) {
|
|
builder.doubleSided(20, 22, 8, 4)
|
|
} else {
|
|
builder.push()
|
|
builder.translate(4, 2, 0)
|
|
builder.scale(8.5 / 8, 4.5 / 4, 1)
|
|
builder.translate(-4, -2, -0.25)
|
|
builder.doubleSided(0, 48, 4, 4)
|
|
builder.translate(4, 0, 0)
|
|
builder.doubleSided(12, 48, 4, 4)
|
|
builder.pop()
|
|
}
|
|
|
|
builder.push()
|
|
builder.translate(0, 4, 0)
|
|
builder.rotate(90, 1, 0, 0)
|
|
if (layer === 0) {
|
|
builder.doubleSided(56, 44, 8, 4)
|
|
} else {
|
|
builder.translate(0, 0, -0.25)
|
|
builder.doubleSided(28, 48, 8, 4, 'NONE', 'NONE', 0.25)
|
|
}
|
|
builder.pop()
|
|
|
|
builder.push()
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.translate(-4, 0, 0.01)
|
|
if (layer === 0) {
|
|
builder.doubleSided(60, 48, 4, 4)
|
|
} else {
|
|
builder.translate(0, 0, -0.25)
|
|
builder.doubleSided(48, 48, 4, 4, 'NONE', 'NONE', 0.25)
|
|
builder.translate(0, 0, 0.25)
|
|
}
|
|
builder.translate(0, 0, 7.98)
|
|
builder.rotate(180, 0, 1, 0)
|
|
builder.translate(-4, 0, 0)
|
|
if (layer === 0) {
|
|
builder.doubleSided(60, 48, 4, 4, 'NONE', 'HORIZONTAL')
|
|
} else {
|
|
builder.translate(0, 0, -0.25)
|
|
builder.doubleSided(48, 48, 4, 4, 'NONE', 'HORIZONTAL', 0.25)
|
|
}
|
|
builder.pop()
|
|
builder.pop()
|
|
}
|
|
}
|
|
|
|
function renderWings(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
const mode = features.wingMode
|
|
if (mode === 'NONE') return
|
|
|
|
const wiggle = features.wingAnimationMode !== 'NONE' ? Math.sin(8 / 12) * 2 : 0
|
|
builder.push()
|
|
builder.anchorTo('TORSO')
|
|
builder.bind('wing')
|
|
builder.translate(2, -14, 4)
|
|
|
|
if (mode === 'SYMMETRIC_DUAL' || mode === 'ASYMMETRIC_R') {
|
|
builder.push()
|
|
builder.rotate(-120 + wiggle, 0, 1, 0)
|
|
builder.doubleSided(0, 0, 20, 16)
|
|
builder.pop()
|
|
}
|
|
if (mode === 'SYMMETRIC_DUAL' || mode === 'ASYMMETRIC_L') {
|
|
builder.translate(4, 0, 0)
|
|
builder.push()
|
|
builder.rotate(-60 - wiggle, 0, 1, 0)
|
|
builder.doubleSided(0, 0, 20, 16)
|
|
builder.pop()
|
|
}
|
|
if (mode === 'SYMMETRIC_SINGLE') {
|
|
builder.translate(2, 0, 0)
|
|
builder.push()
|
|
builder.rotate(-90 + wiggle, 0, 1, 0)
|
|
builder.doubleSided(0, 0, 20, 16)
|
|
builder.pop()
|
|
}
|
|
if (mode === 'ASYMMETRIC_DUAL') {
|
|
builder.push()
|
|
builder.rotate(-120 + wiggle, 0, 1, 0)
|
|
builder.doubleSided(0, 0, 10, 16)
|
|
builder.pop()
|
|
builder.translate(4, 0, 0)
|
|
builder.push()
|
|
builder.rotate(-60 - wiggle, 0, 1, 0)
|
|
builder.doubleSided(10, 0, 10, 16)
|
|
builder.pop()
|
|
}
|
|
if (mode === 'FLAT') {
|
|
builder.translate(-8, 0, 0.75)
|
|
builder.doubleSided(0, 0, 20, 16)
|
|
}
|
|
builder.bind('skin')
|
|
builder.pop()
|
|
}
|
|
|
|
function renderCape(builder: EarsGeometryBuilder, features: EarsFeatures) {
|
|
if (!features.capeEnabled) return
|
|
|
|
builder.push()
|
|
builder.anchorTo('TORSO')
|
|
builder.translate(4, -12, 5)
|
|
builder.rotate(6, 1, 0, 0)
|
|
builder.rotate(180, 0, 1, 0)
|
|
builder.bind('cape')
|
|
builder.translate(-5, 0, 0)
|
|
builder.doubleSided(0, 0, 10, 16)
|
|
|
|
builder.push()
|
|
builder.translate(10, 0, 1)
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.doubleSided(9, 0, 1, 16, 'NONE', 'HORIZONTAL')
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.doubleSided(10, 0, 10, 16)
|
|
builder.translate(10, 0, 1)
|
|
builder.rotate(90, 0, 1, 0)
|
|
builder.doubleSided(0, 0, 1, 16, 'NONE', 'HORIZONTAL')
|
|
builder.pop()
|
|
|
|
builder.rotate(90, 1, 0, 0)
|
|
builder.doubleSided(0, 0, 10, 1, 'NONE', 'VERTICAL')
|
|
builder.translate(0, 0, -16)
|
|
builder.doubleSided(0, 15, 10, 1, 'NONE', 'VERTICAL')
|
|
builder.bind('skin')
|
|
builder.pop()
|
|
}
|
|
|
|
function findBaseMesh(node: THREE.Object3D) {
|
|
const directMesh = node.children.find(
|
|
(child) => (child as THREE.Mesh).isMesh && child.name === `${node.name}_2`,
|
|
) as THREE.Mesh | undefined
|
|
if (directMesh) return directMesh
|
|
|
|
let result: THREE.Mesh | null = null
|
|
node.traverse((object) => {
|
|
if (
|
|
!result &&
|
|
(object as THREE.Mesh).isMesh &&
|
|
object.name.endsWith('_2') &&
|
|
!object.name.startsWith(EARS_FEATURE_PREFIX)
|
|
) {
|
|
result = object as THREE.Mesh
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
function createAnchors(model: THREE.Object3D, resources: EarsModResources) {
|
|
const anchors = new Map<BodyPart, Anchor>()
|
|
|
|
for (const [part, nodeName] of Object.entries(BODY_PART_NODES) as [BodyPart, string][]) {
|
|
const node = model.getObjectByName(nodeName)
|
|
if (!node) continue
|
|
const baseMesh = findBaseMesh(node)
|
|
if (!baseMesh?.geometry) continue
|
|
|
|
if (!baseMesh.geometry.boundingBox) baseMesh.geometry.computeBoundingBox()
|
|
if (!baseMesh.geometry.boundingBox) continue
|
|
baseMesh.updateMatrix()
|
|
const box = baseMesh.geometry.boundingBox.clone().applyMatrix4(baseMesh.matrix)
|
|
const group = new THREE.Group()
|
|
group.name = `${EARS_FEATURE_PREFIX}:${part.toLowerCase()}`
|
|
group.scale.setScalar(1 / 16)
|
|
node.add(group)
|
|
resources.featureGroups.push(group)
|
|
anchors.set(part, {
|
|
group,
|
|
origin: new THREE.Vector3(box.max.x * 16, box.min.y * 16, box.min.z * 16),
|
|
})
|
|
}
|
|
|
|
return anchors
|
|
}
|
|
|
|
function isSlimModel(model: THREE.Object3D) {
|
|
const arm = model.getObjectByName('Right_Arm')
|
|
const mesh = arm ? findBaseMesh(arm) : null
|
|
if (!mesh?.geometry) return false
|
|
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
|
const box = mesh.geometry.boundingBox
|
|
return box ? box.max.x - box.min.x < 0.24 : false
|
|
}
|
|
|
|
function applySkinTexture(model: THREE.Object3D, texture: THREE.Texture) {
|
|
model.traverse((object) => {
|
|
const mesh = object as THREE.Mesh
|
|
if (!mesh.isMesh || !mesh.material || object.name.startsWith(EARS_FEATURE_PREFIX)) return
|
|
|
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|
for (const material of materials) {
|
|
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') continue
|
|
material.map = texture
|
|
material.needsUpdate = true
|
|
}
|
|
})
|
|
}
|
|
|
|
function addEmissiveBodyOverlays(
|
|
model: THREE.Object3D,
|
|
texture: THREE.Texture,
|
|
resources: EarsModResources,
|
|
) {
|
|
const meshes: THREE.Mesh[] = []
|
|
model.traverse((object) => {
|
|
const mesh = object as THREE.Mesh
|
|
if (
|
|
mesh.isMesh &&
|
|
mesh.material &&
|
|
!object.name.startsWith(EARS_FEATURE_PREFIX) &&
|
|
!object.name.startsWith(EARS_OVERLAY_PREFIX)
|
|
) {
|
|
meshes.push(mesh)
|
|
}
|
|
})
|
|
|
|
for (const mesh of meshes) {
|
|
const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|
if (
|
|
sourceMaterials.every(
|
|
(material) => !(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape',
|
|
)
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const material = makeFeatureMaterial(texture, true)
|
|
resources.featureMaterials.add(material)
|
|
const overlay = new THREE.Mesh(mesh.geometry, material)
|
|
overlay.name = `${EARS_OVERLAY_PREFIX}:${mesh.name}`
|
|
overlay.renderOrder = 2
|
|
mesh.add(overlay)
|
|
resources.emissiveOverlays.push(overlay)
|
|
}
|
|
}
|
|
|
|
function disposeFeatureGroup(group: THREE.Group) {
|
|
group.traverse((object) => {
|
|
const mesh = object as THREE.Mesh
|
|
if (mesh.isMesh) mesh.geometry.dispose()
|
|
})
|
|
group.removeFromParent()
|
|
}
|
|
|
|
export function removeEarsMod(model: THREE.Object3D | null) {
|
|
if (!model) return
|
|
const registry = model.getObjectByName(EARS_REGISTRY_NAME)
|
|
const resources = registry?.userData.earsMod as EarsModResources | undefined
|
|
if (!registry || !resources) return
|
|
|
|
resources.disposed = true
|
|
for (const image of resources.pendingImages) {
|
|
image.onload = null
|
|
image.onerror = null
|
|
image.src = ''
|
|
}
|
|
for (const objectUrl of resources.objectUrls) URL.revokeObjectURL(objectUrl)
|
|
for (const overlay of resources.emissiveOverlays) overlay.removeFromParent()
|
|
for (const group of resources.featureGroups) disposeFeatureGroup(group)
|
|
for (const material of resources.featureMaterials) material.dispose()
|
|
for (const texture of resources.featureTextures) texture.dispose()
|
|
registry.removeFromParent()
|
|
}
|
|
|
|
export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture, enabled = true) {
|
|
removeEarsMod(model)
|
|
|
|
const sourceImage = readSkinImage(sourceTexture)
|
|
if (!sourceImage) return false
|
|
const features = parseFeatures(sourceImage.data)
|
|
if (!features) return false
|
|
if (!enabled) return true
|
|
|
|
const resources: EarsModResources = {
|
|
featureGroups: [],
|
|
featureMaterials: new Set(),
|
|
featureTextures: new Set(),
|
|
emissiveOverlays: [],
|
|
pendingImages: [],
|
|
objectUrls: new Set(),
|
|
disposed: false,
|
|
}
|
|
const registry = new THREE.Group()
|
|
registry.name = EARS_REGISTRY_NAME
|
|
registry.userData.earsMod = resources
|
|
model.add(registry)
|
|
|
|
const alfalfa = parseAlfalfa(sourceImage.data)
|
|
const processedData = new Uint8ClampedArray(sourceImage.data)
|
|
applyEraseData(processedData, alfalfa.get('erase'))
|
|
const displacedData = displaceDigitigradeLegPixels(processedData, features.legMode)
|
|
if (features.swapJacketBackAndTail) swapJacketBackAndTailPixels(processedData)
|
|
forceOpaqueSkinRegions(processedData, features.legMode)
|
|
|
|
const palette = features.emissive ? getEmissivePalette(processedData) : new Set<number>()
|
|
if (palette.size === 0) features.emissive = false
|
|
const skinPixels = features.emissive
|
|
? splitEmissivePixels(processedData, palette)
|
|
: { base: processedData, emissive: undefined }
|
|
const skinTextures: TexturePair = {
|
|
base: createCanvasTexture(skinPixels.base, SKIN_SIZE, SKIN_SIZE),
|
|
emissive: skinPixels.emissive
|
|
? createCanvasTexture(skinPixels.emissive, SKIN_SIZE, SKIN_SIZE)
|
|
: undefined,
|
|
}
|
|
resources.featureTextures.add(skinTextures.base)
|
|
if (skinTextures.emissive) resources.featureTextures.add(skinTextures.emissive)
|
|
applySkinTexture(model, skinTextures.base)
|
|
if (skinTextures.emissive) addEmissiveBodyOverlays(model, skinTextures.emissive, resources)
|
|
|
|
const textures = new Map<TextureSource, TexturePair>([['skin', skinTextures]])
|
|
if (displacedData) {
|
|
const displacedPixels = features.emissive
|
|
? splitEmissivePixels(displacedData, palette)
|
|
: { base: displacedData, emissive: undefined }
|
|
const displacedTextures: TexturePair = {
|
|
base: createCanvasTexture(displacedPixels.base, SKIN_SIZE, SKIN_SIZE),
|
|
emissive: displacedPixels.emissive
|
|
? createCanvasTexture(displacedPixels.emissive, SKIN_SIZE, SKIN_SIZE)
|
|
: undefined,
|
|
}
|
|
resources.featureTextures.add(displacedTextures.base)
|
|
if (displacedTextures.emissive) resources.featureTextures.add(displacedTextures.emissive)
|
|
textures.set('displaced-skin', displacedTextures)
|
|
}
|
|
const wingData = alfalfa.get('wing')
|
|
const wingDimensions = wingData ? readPngDimensions(wingData) : null
|
|
if (
|
|
features.wingMode !== 'NONE' &&
|
|
wingData &&
|
|
wingDimensions &&
|
|
((wingDimensions[0] === 20 && wingDimensions[1] === 16) ||
|
|
(wingDimensions[0] === 12 && wingDimensions[1] === 12))
|
|
) {
|
|
textures.set(
|
|
'wing',
|
|
createEmbeddedTexture(
|
|
wingData,
|
|
20,
|
|
16,
|
|
resources,
|
|
features.emissive ? palette : undefined,
|
|
wingDimensions[0] === 12,
|
|
),
|
|
)
|
|
} else {
|
|
features.wingMode = 'NONE'
|
|
}
|
|
|
|
const capeData = alfalfa.get('cape')
|
|
const capeDimensions = capeData ? readPngDimensions(capeData) : null
|
|
if (features.capeEnabled && capeData && capeDimensions?.[0] === 20 && capeDimensions[1] === 16) {
|
|
textures.set('cape', createEmbeddedTexture(capeData, 20, 16, resources))
|
|
} else {
|
|
features.capeEnabled = false
|
|
}
|
|
|
|
const anchors = createAnchors(model, resources)
|
|
const builder = new EarsGeometryBuilder(anchors, textures, resources)
|
|
renderEars(builder, features)
|
|
renderTail(builder, features)
|
|
renderProtrusions(builder, features, isSlimModel(model))
|
|
renderSnout(builder, features)
|
|
renderChest(builder, features)
|
|
renderWings(builder, features)
|
|
renderCape(builder, features)
|
|
renderDigitigradeLegs(builder, features)
|
|
return true
|
|
}
|