diff --git a/src-tauri/migrations/20260829010000_add_puppet_opacity.sql b/src-tauri/migrations/20260829010000_add_puppet_opacity.sql new file mode 100644 index 0000000..5389b5f --- /dev/null +++ b/src-tauri/migrations/20260829010000_add_puppet_opacity.sql @@ -0,0 +1,3 @@ +ALTER TABLE scene_configuration +ADD COLUMN puppet_opacity REAL NOT NULL DEFAULT 1.0 +CHECK (puppet_opacity BETWEEN 0.1 AND 1.0); diff --git a/src-tauri/src/scene_configuration.rs b/src-tauri/src/scene_configuration.rs index 8499cde..e74c5e3 100644 --- a/src-tauri/src/scene_configuration.rs +++ b/src-tauri/src/scene_configuration.rs @@ -7,11 +7,14 @@ use tauri_specta::Event; const SCENE_CONFIGURATION_ID: i64 = 1; const MIN_PUPPET_SCALE: f64 = 0.5; const MAX_PUPPET_SCALE: f64 = 2.0; +const MIN_PUPPET_OPACITY: f64 = 0.1; +const MAX_PUPPET_OPACITY: f64 = 1.0; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type, sqlx::FromRow)] #[serde(rename_all = "camelCase")] pub struct SceneConfiguration { pub puppet_scale: f64, + pub puppet_opacity: f64, } #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] @@ -22,7 +25,7 @@ pub struct SceneConfigurationChanged { async fn get(database: &AppDatabase) -> Result { sqlx::query_as::<_, SceneConfiguration>( - "SELECT puppet_scale FROM scene_configuration WHERE id = ?1", + "SELECT puppet_scale, puppet_opacity FROM scene_configuration WHERE id = ?1", ) .bind(SCENE_CONFIGURATION_ID) .fetch_one(database.pool()) @@ -40,13 +43,23 @@ async fn update( "Puppet scale must be between {MIN_PUPPET_SCALE} and {MAX_PUPPET_SCALE}." )); } + if !configuration.puppet_opacity.is_finite() + || !(MIN_PUPPET_OPACITY..=MAX_PUPPET_OPACITY).contains(&configuration.puppet_opacity) + { + return Err(format!( + "Puppet opacity must be between {MIN_PUPPET_OPACITY} and {MAX_PUPPET_OPACITY}." + )); + } - sqlx::query("UPDATE scene_configuration SET puppet_scale = ?1 WHERE id = ?2") - .bind(configuration.puppet_scale) - .bind(SCENE_CONFIGURATION_ID) - .execute(database.pool()) - .await - .map_err(db::command_error)?; + sqlx::query( + "UPDATE scene_configuration SET puppet_scale = ?1, puppet_opacity = ?2 WHERE id = ?3", + ) + .bind(configuration.puppet_scale) + .bind(configuration.puppet_opacity) + .bind(SCENE_CONFIGURATION_ID) + .execute(database.pool()) + .await + .map_err(db::command_error)?; get(database).await.map_err(db::command_error) } @@ -100,29 +113,57 @@ mod tests { } #[tokio::test] - async fn scene_configuration_defaults_to_unit_puppet_scale() { + async fn scene_configuration_defaults_preserve_the_current_appearance() { let database = database().await; assert_eq!( get(&database).await.unwrap(), - SceneConfiguration { puppet_scale: 1.0 } + SceneConfiguration { + puppet_scale: 1.0, + puppet_opacity: 1.0, + } ); } #[tokio::test] - async fn puppet_scale_is_persisted_and_range_checked() { + async fn scene_configuration_is_persisted_and_range_checked() { let database = database().await; - let configuration = update(&database, SceneConfiguration { puppet_scale: 1.5 }) - .await - .expect("update scene configuration"); + let configuration = update( + &database, + SceneConfiguration { + puppet_scale: 1.5, + puppet_opacity: 0.4, + }, + ) + .await + .expect("update scene configuration"); assert_eq!(configuration.puppet_scale, 1.5); + assert_eq!(configuration.puppet_opacity, 0.4); assert_eq!(get(&database).await.unwrap(), configuration); - let error = update(&database, SceneConfiguration { puppet_scale: 2.1 }) - .await - .expect_err("reject an out-of-range puppet scale"); + let error = update( + &database, + SceneConfiguration { + puppet_scale: 2.1, + puppet_opacity: 0.4, + }, + ) + .await + .expect_err("reject an out-of-range puppet scale"); assert_eq!(error, "Puppet scale must be between 0.5 and 2."); assert_eq!(get(&database).await.unwrap().puppet_scale, 1.5); + + let error = update( + &database, + SceneConfiguration { + puppet_scale: 1.5, + puppet_opacity: 0.05, + }, + ) + .await + .expect_err("reject an out-of-range puppet opacity"); + assert_eq!(error, "Puppet opacity must be between 0.1 and 1."); + assert_eq!(get(&database).await.unwrap().puppet_opacity, 0.4); } } diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 6fb80d2..f0a5da9 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -149,7 +149,7 @@ export type PuppetStatesChanged = { puppets: PuppetState[] } export type Remote = { id: string; address: string; name: string | null; port: number | null } export type RemoteInput = { address: string; name: string | null; port: number | null } export type RemotesChanged = { remotes: Remote[] } -export type SceneConfiguration = { puppetScale: number } +export type SceneConfiguration = { puppetScale: number; puppetOpacity: number } export type SceneConfigurationChanged = { configuration: SceneConfiguration } export type SceneHitbox = { x: number; y: number; width: number; height: number } /** diff --git a/src/lib/components/control-panel/scene-panel.svelte b/src/lib/components/control-panel/scene-panel.svelte index c6ad72c..a2303be 100644 --- a/src/lib/components/control-panel/scene-panel.svelte +++ b/src/lib/components/control-panel/scene-panel.svelte @@ -11,24 +11,31 @@ let { register }: { register: RegisterPanel } = $props(); let puppetScale = $state(1); + let puppetOpacity = $state(1); let dirty = $state(false); let busy = $state(false); let error = $state(""); $effect(() => { - if (!dirty) puppetScale = $sceneConfiguration.puppetScale; + if (!dirty) { + puppetScale = $sceneConfiguration.puppetScale; + puppetOpacity = $sceneConfiguration.puppetOpacity; + } register("scene", { apply, reset }, { dirty, busy }); }); onMount(() => register("scene", { apply, reset }, { dirty, busy })); function updateDirty() { - dirty = puppetScale !== $sceneConfiguration.puppetScale; + dirty = + puppetScale !== $sceneConfiguration.puppetScale || + puppetOpacity !== $sceneConfiguration.puppetOpacity; error = ""; } function reset() { puppetScale = $sceneConfiguration.puppetScale; + puppetOpacity = $sceneConfiguration.puppetOpacity; dirty = false; error = ""; } @@ -41,8 +48,10 @@ try { const configuration = await commands.updateSceneConfiguration({ puppetScale, + puppetOpacity, }); puppetScale = configuration.puppetScale; + puppetOpacity = configuration.puppetOpacity; dirty = false; return true; } catch (cause) { @@ -82,5 +91,24 @@ disabled={busy} aria-describedby="puppet-scale-help" /> + +
+ + + {Math.round(puppetOpacity * 100)}% + +
+ diff --git a/src/lib/listeners/scene-configuration.ts b/src/lib/listeners/scene-configuration.ts index d256253..1fbc416 100644 --- a/src/lib/listeners/scene-configuration.ts +++ b/src/lib/listeners/scene-configuration.ts @@ -7,6 +7,7 @@ import { export const sceneConfiguration = writable({ puppetScale: 1, + puppetOpacity: 1, }); export const sceneConfigurationListenerError = writable(""); diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index 1a6e8df..f28f2ae 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -7,6 +7,7 @@ import { liveMetadata } from "$lib/listeners/live-metadata"; import { profile } from "$lib/listeners/profile"; import { puppetStates } from "$lib/listeners/puppets"; + import { sceneConfiguration } from "$lib/listeners/scene-configuration"; import Renderer from "./components/renderer.svelte"; import type { PuppetScreenBounds } from "./components/renderer/types"; import { startHitboxSync } from "./hitboxes"; @@ -102,7 +103,7 @@
(puppetBounds = bounds)} {skinHashes} /> @@ -140,6 +141,7 @@ src={`data:image/png;base64,${foregroundApp.ico}`} alt="" class="pointer-events-none absolute -top-5 left-1/2 size-4 -translate-x-1/2 object-contain" + style:opacity={$sceneConfiguration.puppetOpacity} /> {/if} diff --git a/src/routes/scene/components/renderer.svelte b/src/routes/scene/components/renderer.svelte index 0d6d6e1..fbc4d83 100644 --- a/src/routes/scene/components/renderer.svelte +++ b/src/routes/scene/components/renderer.svelte @@ -9,12 +9,12 @@ let { puppets, - frozenPuppetId, + selectedPuppetId, onBoundsChange, skinHashes, }: { puppets: readonly PuppetState[]; - frozenPuppetId: string | null; + selectedPuppetId: string | null; onBoundsChange: (bounds: PuppetScreenBounds[]) => void; skinHashes: ReadonlyMap; } = $props(); @@ -38,7 +38,8 @@ puppetManager.update( puppets, $sceneConfiguration.puppetScale, - frozenPuppetId, + $sceneConfiguration.puppetOpacity, + selectedPuppetId, clock.getDelta(), clock.getElapsed(), skinHashes, diff --git a/src/routes/scene/components/renderer/puppet/index.ts b/src/routes/scene/components/renderer/puppet/index.ts index 1d03f54..818986f 100644 --- a/src/routes/scene/components/renderer/puppet/index.ts +++ b/src/routes/scene/components/renderer/puppet/index.ts @@ -54,8 +54,10 @@ export class Puppet { deltaSeconds: number, elapsedSeconds: number, skinHash: string | null, + opacity: number, ) { this.skin.update(skinHash); + this.skin.setOpacity(opacity); if (frozen) { this.animation.pause(); return; @@ -77,11 +79,12 @@ export class Puppet { dispose() { this.skin.dispose(); + const geometries = new Set(); this.root.traverse((object) => { if (!(object instanceof THREE.Mesh)) return; - - object.geometry.dispose(); + geometries.add(object.geometry); }); + for (const geometry of geometries) geometry.dispose(); } private createRig(): PuppetRig { diff --git a/src/routes/scene/components/renderer/puppet/manager.ts b/src/routes/scene/components/renderer/puppet/manager.ts index c297bf4..78505b0 100644 --- a/src/routes/scene/components/renderer/puppet/manager.ts +++ b/src/routes/scene/components/renderer/puppet/manager.ts @@ -11,7 +11,8 @@ export class PuppetManager { update( states: readonly PuppetState[], scale: number, - frozenPuppetId: string | null, + idleOpacity: number, + selectedPuppetId: string | null, deltaSeconds: number, elapsedSeconds: number, skinHashes: ReadonlyMap, @@ -24,10 +25,11 @@ export class PuppetManager { ?.update( state, this.world, - state.id === frozenPuppetId, + state.id === selectedPuppetId, deltaSeconds, elapsedSeconds, skinHashes.get(state.id) ?? null, + state.id === selectedPuppetId ? 1 : idleOpacity, ); } } diff --git a/src/routes/scene/components/renderer/puppet/skin.ts b/src/routes/scene/components/renderer/puppet/skin.ts index 1ec6d94..b4fb427 100644 --- a/src/routes/scene/components/renderer/puppet/skin.ts +++ b/src/routes/scene/components/renderer/puppet/skin.ts @@ -118,6 +118,7 @@ function loadTexture(url: string) { export class PuppetSkin { private readonly material: THREE.MeshStandardMaterial; + private readonly depthMaterial: THREE.MeshBasicMaterial; private texture: THREE.Texture | null = null; private requestedHash: string | null | undefined; private requestId = 0; @@ -130,6 +131,14 @@ export class PuppetSkin { color: "white", transparent: true, alphaTest: 0.5, + depthWrite: false, + depthFunc: THREE.EqualDepth, + }); + this.depthMaterial = new THREE.MeshBasicMaterial({ + colorWrite: false, + depthWrite: true, + depthTest: true, + alphaTest: 0.5, }); const parts: SkinPart[] = [ { mesh: rig.head, faces: HEAD }, @@ -142,6 +151,11 @@ export class PuppetSkin { for (const part of parts) { part.mesh.material = this.material; applyUvs(part.mesh.geometry, part.faces); + + // Opaque-phase depth establishes the nearest rig surface before the + // translucent parent mesh contributes color. + const depthMesh = new THREE.Mesh(part.mesh.geometry, this.depthMaterial); + part.mesh.add(depthMesh); } } @@ -161,16 +175,25 @@ export class PuppetSkin { this.texture?.dispose(); this.texture = texture; this.material.map = texture; + this.depthMaterial.map = texture; this.material.needsUpdate = true; + this.depthMaterial.needsUpdate = true; }) .catch((error) => console.error(`Failed to load skin for ${this.userId}`, error), ); } + setOpacity(opacity: number) { + if (this.material.opacity === opacity) return; + this.material.opacity = opacity; + this.material.alphaTest = opacity * 0.5; + } + dispose() { this.requestId++; this.texture?.dispose(); this.material.dispose(); + this.depthMaterial.dispose(); } }