puppet opacity

This commit is contained in:
2026-08-29 19:11:49 +08:00
parent 55ed1f9d32
commit 7be507bff2
10 changed files with 131 additions and 27 deletions
@@ -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);
+48 -7
View File
@@ -7,11 +7,14 @@ use tauri_specta::Event;
const SCENE_CONFIGURATION_ID: i64 = 1; const SCENE_CONFIGURATION_ID: i64 = 1;
const MIN_PUPPET_SCALE: f64 = 0.5; const MIN_PUPPET_SCALE: f64 = 0.5;
const MAX_PUPPET_SCALE: f64 = 2.0; 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SceneConfiguration { pub struct SceneConfiguration {
pub puppet_scale: f64, pub puppet_scale: f64,
pub puppet_opacity: f64,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
@@ -22,7 +25,7 @@ pub struct SceneConfigurationChanged {
async fn get(database: &AppDatabase) -> Result<SceneConfiguration, sqlx::Error> { async fn get(database: &AppDatabase) -> Result<SceneConfiguration, sqlx::Error> {
sqlx::query_as::<_, SceneConfiguration>( 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) .bind(SCENE_CONFIGURATION_ID)
.fetch_one(database.pool()) .fetch_one(database.pool())
@@ -40,9 +43,19 @@ async fn update(
"Puppet scale must be between {MIN_PUPPET_SCALE} and {MAX_PUPPET_SCALE}." "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") sqlx::query(
"UPDATE scene_configuration SET puppet_scale = ?1, puppet_opacity = ?2 WHERE id = ?3",
)
.bind(configuration.puppet_scale) .bind(configuration.puppet_scale)
.bind(configuration.puppet_opacity)
.bind(SCENE_CONFIGURATION_ID) .bind(SCENE_CONFIGURATION_ID)
.execute(database.pool()) .execute(database.pool())
.await .await
@@ -100,29 +113,57 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn scene_configuration_defaults_to_unit_puppet_scale() { async fn scene_configuration_defaults_preserve_the_current_appearance() {
let database = database().await; let database = database().await;
assert_eq!( assert_eq!(
get(&database).await.unwrap(), get(&database).await.unwrap(),
SceneConfiguration { puppet_scale: 1.0 } SceneConfiguration {
puppet_scale: 1.0,
puppet_opacity: 1.0,
}
); );
} }
#[tokio::test] #[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 database = database().await;
let configuration = update(&database, SceneConfiguration { puppet_scale: 1.5 }) let configuration = update(
&database,
SceneConfiguration {
puppet_scale: 1.5,
puppet_opacity: 0.4,
},
)
.await .await
.expect("update scene configuration"); .expect("update scene configuration");
assert_eq!(configuration.puppet_scale, 1.5); assert_eq!(configuration.puppet_scale, 1.5);
assert_eq!(configuration.puppet_opacity, 0.4);
assert_eq!(get(&database).await.unwrap(), configuration); assert_eq!(get(&database).await.unwrap(), configuration);
let error = update(&database, SceneConfiguration { puppet_scale: 2.1 }) let error = update(
&database,
SceneConfiguration {
puppet_scale: 2.1,
puppet_opacity: 0.4,
},
)
.await .await
.expect_err("reject an out-of-range puppet scale"); .expect_err("reject an out-of-range puppet scale");
assert_eq!(error, "Puppet scale must be between 0.5 and 2."); assert_eq!(error, "Puppet scale must be between 0.5 and 2.");
assert_eq!(get(&database).await.unwrap().puppet_scale, 1.5); 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);
} }
} }
+1 -1
View File
@@ -149,7 +149,7 @@ export type PuppetStatesChanged = { puppets: PuppetState[] }
export type Remote = { id: string; address: string; name: string | null; port: number | null } 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 RemoteInput = { address: string; name: string | null; port: number | null }
export type RemotesChanged = { remotes: Remote[] } export type RemotesChanged = { remotes: Remote[] }
export type SceneConfiguration = { puppetScale: number } export type SceneConfiguration = { puppetScale: number; puppetOpacity: number }
export type SceneConfigurationChanged = { configuration: SceneConfiguration } export type SceneConfigurationChanged = { configuration: SceneConfiguration }
export type SceneHitbox = { x: number; y: number; width: number; height: number } export type SceneHitbox = { x: number; y: number; width: number; height: number }
/** /**
@@ -11,24 +11,31 @@
let { register }: { register: RegisterPanel } = $props(); let { register }: { register: RegisterPanel } = $props();
let puppetScale = $state(1); let puppetScale = $state(1);
let puppetOpacity = $state(1);
let dirty = $state(false); let dirty = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(""); let error = $state("");
$effect(() => { $effect(() => {
if (!dirty) puppetScale = $sceneConfiguration.puppetScale; if (!dirty) {
puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity;
}
register("scene", { apply, reset }, { dirty, busy }); register("scene", { apply, reset }, { dirty, busy });
}); });
onMount(() => register("scene", { apply, reset }, { dirty, busy })); onMount(() => register("scene", { apply, reset }, { dirty, busy }));
function updateDirty() { function updateDirty() {
dirty = puppetScale !== $sceneConfiguration.puppetScale; dirty =
puppetScale !== $sceneConfiguration.puppetScale ||
puppetOpacity !== $sceneConfiguration.puppetOpacity;
error = ""; error = "";
} }
function reset() { function reset() {
puppetScale = $sceneConfiguration.puppetScale; puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity;
dirty = false; dirty = false;
error = ""; error = "";
} }
@@ -41,8 +48,10 @@
try { try {
const configuration = await commands.updateSceneConfiguration({ const configuration = await commands.updateSceneConfiguration({
puppetScale, puppetScale,
puppetOpacity,
}); });
puppetScale = configuration.puppetScale; puppetScale = configuration.puppetScale;
puppetOpacity = configuration.puppetOpacity;
dirty = false; dirty = false;
return true; return true;
} catch (cause) { } catch (cause) {
@@ -82,5 +91,24 @@
disabled={busy} disabled={busy}
aria-describedby="puppet-scale-help" aria-describedby="puppet-scale-help"
/> />
<div class="mt-3 flex items-center justify-between gap-3">
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
<output class="badge badge-outline tabular-nums" for="puppet-opacity">
{Math.round(puppetOpacity * 100)}%
</output>
</div>
<input
id="puppet-opacity"
class="range range-sm"
type="range"
min="0.25"
max="1"
step="0.05"
bind:value={puppetOpacity}
oninput={updateDirty}
disabled={busy}
aria-describedby="puppet-opacity-help"
/>
</fieldset> </fieldset>
</div> </div>
+1
View File
@@ -7,6 +7,7 @@ import {
export const sceneConfiguration = writable<SceneConfiguration>({ export const sceneConfiguration = writable<SceneConfiguration>({
puppetScale: 1, puppetScale: 1,
puppetOpacity: 1,
}); });
export const sceneConfigurationListenerError = writable(""); export const sceneConfigurationListenerError = writable("");
+3 -1
View File
@@ -7,6 +7,7 @@
import { liveMetadata } from "$lib/listeners/live-metadata"; import { liveMetadata } from "$lib/listeners/live-metadata";
import { profile } from "$lib/listeners/profile"; import { profile } from "$lib/listeners/profile";
import { puppetStates } from "$lib/listeners/puppets"; import { puppetStates } from "$lib/listeners/puppets";
import { sceneConfiguration } from "$lib/listeners/scene-configuration";
import Renderer from "./components/renderer.svelte"; import Renderer from "./components/renderer.svelte";
import type { PuppetScreenBounds } from "./components/renderer/types"; import type { PuppetScreenBounds } from "./components/renderer/types";
import { startHitboxSync } from "./hitboxes"; import { startHitboxSync } from "./hitboxes";
@@ -102,7 +103,7 @@
<div class="relative size-full"> <div class="relative size-full">
<Renderer <Renderer
puppets={visiblePuppets} puppets={visiblePuppets}
frozenPuppetId={selectedUserId} selectedPuppetId={selectedUserId}
onBoundsChange={(bounds) => (puppetBounds = bounds)} onBoundsChange={(bounds) => (puppetBounds = bounds)}
{skinHashes} {skinHashes}
/> />
@@ -140,6 +141,7 @@
src={`data:image/png;base64,${foregroundApp.ico}`} src={`data:image/png;base64,${foregroundApp.ico}`}
alt="" alt=""
class="pointer-events-none absolute -top-5 left-1/2 size-4 -translate-x-1/2 object-contain" class="pointer-events-none absolute -top-5 left-1/2 size-4 -translate-x-1/2 object-contain"
style:opacity={$sceneConfiguration.puppetOpacity}
/> />
{/if} {/if}
+4 -3
View File
@@ -9,12 +9,12 @@
let { let {
puppets, puppets,
frozenPuppetId, selectedPuppetId,
onBoundsChange, onBoundsChange,
skinHashes, skinHashes,
}: { }: {
puppets: readonly PuppetState[]; puppets: readonly PuppetState[];
frozenPuppetId: string | null; selectedPuppetId: string | null;
onBoundsChange: (bounds: PuppetScreenBounds[]) => void; onBoundsChange: (bounds: PuppetScreenBounds[]) => void;
skinHashes: ReadonlyMap<string, string | null>; skinHashes: ReadonlyMap<string, string | null>;
} = $props(); } = $props();
@@ -38,7 +38,8 @@
puppetManager.update( puppetManager.update(
puppets, puppets,
$sceneConfiguration.puppetScale, $sceneConfiguration.puppetScale,
frozenPuppetId, $sceneConfiguration.puppetOpacity,
selectedPuppetId,
clock.getDelta(), clock.getDelta(),
clock.getElapsed(), clock.getElapsed(),
skinHashes, skinHashes,
@@ -54,8 +54,10 @@ export class Puppet {
deltaSeconds: number, deltaSeconds: number,
elapsedSeconds: number, elapsedSeconds: number,
skinHash: string | null, skinHash: string | null,
opacity: number,
) { ) {
this.skin.update(skinHash); this.skin.update(skinHash);
this.skin.setOpacity(opacity);
if (frozen) { if (frozen) {
this.animation.pause(); this.animation.pause();
return; return;
@@ -77,11 +79,12 @@ export class Puppet {
dispose() { dispose() {
this.skin.dispose(); this.skin.dispose();
const geometries = new Set<THREE.BufferGeometry>();
this.root.traverse((object) => { this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return; if (!(object instanceof THREE.Mesh)) return;
geometries.add(object.geometry);
object.geometry.dispose();
}); });
for (const geometry of geometries) geometry.dispose();
} }
private createRig(): PuppetRig { private createRig(): PuppetRig {
@@ -11,7 +11,8 @@ export class PuppetManager {
update( update(
states: readonly PuppetState[], states: readonly PuppetState[],
scale: number, scale: number,
frozenPuppetId: string | null, idleOpacity: number,
selectedPuppetId: string | null,
deltaSeconds: number, deltaSeconds: number,
elapsedSeconds: number, elapsedSeconds: number,
skinHashes: ReadonlyMap<string, string | null>, skinHashes: ReadonlyMap<string, string | null>,
@@ -24,10 +25,11 @@ export class PuppetManager {
?.update( ?.update(
state, state,
this.world, this.world,
state.id === frozenPuppetId, state.id === selectedPuppetId,
deltaSeconds, deltaSeconds,
elapsedSeconds, elapsedSeconds,
skinHashes.get(state.id) ?? null, skinHashes.get(state.id) ?? null,
state.id === selectedPuppetId ? 1 : idleOpacity,
); );
} }
} }
@@ -118,6 +118,7 @@ function loadTexture(url: string) {
export class PuppetSkin { export class PuppetSkin {
private readonly material: THREE.MeshStandardMaterial; private readonly material: THREE.MeshStandardMaterial;
private readonly depthMaterial: THREE.MeshBasicMaterial;
private texture: THREE.Texture | null = null; private texture: THREE.Texture | null = null;
private requestedHash: string | null | undefined; private requestedHash: string | null | undefined;
private requestId = 0; private requestId = 0;
@@ -130,6 +131,14 @@ export class PuppetSkin {
color: "white", color: "white",
transparent: true, transparent: true,
alphaTest: 0.5, 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[] = [ const parts: SkinPart[] = [
{ mesh: rig.head, faces: HEAD }, { mesh: rig.head, faces: HEAD },
@@ -142,6 +151,11 @@ export class PuppetSkin {
for (const part of parts) { for (const part of parts) {
part.mesh.material = this.material; part.mesh.material = this.material;
applyUvs(part.mesh.geometry, part.faces); 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?.dispose();
this.texture = texture; this.texture = texture;
this.material.map = texture; this.material.map = texture;
this.depthMaterial.map = texture;
this.material.needsUpdate = true; this.material.needsUpdate = true;
this.depthMaterial.needsUpdate = true;
}) })
.catch((error) => .catch((error) =>
console.error(`Failed to load skin for ${this.userId}`, 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() { dispose() {
this.requestId++; this.requestId++;
this.texture?.dispose(); this.texture?.dispose();
this.material.dispose(); this.material.dispose();
this.depthMaterial.dispose();
} }
} }