puppet opacity
This commit is contained in:
@@ -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);
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user