/* * 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 featureTextures: Set emissiveOverlays: THREE.Mesh[] pendingImages: HTMLImageElement[] objectUrls: Set 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([ '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 = { 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([ [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([ [MAGIC_PIXELS.BLUE, 'CENTER'], [MAGIC_PIXELS.GREEN, 'FRONT'], [MAGIC_PIXELS.RED, 'BACK'], ]) const tailModeByPixel = new Map([ [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([ [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([ [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([ [MAGIC_PIXELS.BLUE, 'NORMAL'], [MAGIC_PIXELS.RED, 'NONE'], [MAGIC_PIXELS.GREEN, 'NO_FLIGHT'], ]) const legModeByPixel = new Map([ [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() 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() } const entries = new Map() 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() } } 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() 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) { 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, 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() private readonly stack: RenderState[] = [] private state: RenderState = { anchor: null, matrix: new THREE.Matrix4(), } private textureSource: TextureSource = 'skin' constructor( private readonly anchors: Map, private readonly textures: Map, 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() 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() 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([['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 }