diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 387bed2b9..dd0f477a6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -52,10 +52,12 @@ jobs:
uses: ./.github/actions/setup
# STT is the bundled whisper-stt-server (whisper.cpp with native DTW token
- # timestamps); no VAD model is fetched here. The binary is built by
- # build-whisper-stt.yml and staged below — without that step the installer
- # ships without speech-to-text. See
- # technical-documentation/architecture/transcription-and-captions.md.
+ # timestamps); no model is fetched here. The binary is built by
+ # build-whisper-stt.yml and staged below, from the run built from this
+ # commit's helper sources — without that step the installer ships without
+ # speech-to-text. See
+ # technical-documentation/architecture/transcription-and-captions.md, and
+ # technical-documentation/engineering/release-and-secrets.md for the run.
- name: Stage whisper-stt binaries
shell: bash
env:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index e8a87f80f..8565f14e9 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -76,6 +76,17 @@ jobs:
- name: Check media budget
working-directory: website
run: npm run check:media
+ # The demo loops are served from R2 (media.getopenscreen.com), not git, so
+ # nothing above would notice a loop a page names that was never uploaded,
+ # or a cut folder holding the wrong files. media/loops.json records what
+ # scripts/media/publish-loops.mjs uploaded; this holds the code to it and
+ # asks the CDN for every recorded file, paced under the zone's rate limit.
+ - name: Check demo loops
+ working-directory: website
+ run: npm run check:loops -- --live
+ - name: Check responsive backgrounds
+ working-directory: website
+ run: npm run check:backgrounds -- --live
# The recreation's data is generated from the app's own source and a
# vendored project document. It is committed, so it can go stale in either
# direction — a locale key moves, a timeline constant changes — and the
@@ -137,4 +148,4 @@ jobs:
id-token: write
steps:
- id: deployment
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
\ No newline at end of file
+ uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
diff --git a/.gitignore b/.gitignore
index 24ad425eb..8b2682b6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,6 +95,9 @@ result-*
/tools/stt-eval/whispercpp-dtw-poc/fixtures/*.wav
/tools/stt-eval/whispercpp-dtw-poc/results/
+# Word-timing harness (tools/stt-eval/word-timing): TTS corpus and results.
+/tools/stt-eval/word-timing/data/
+
opencode.json
opencode.json
diff --git a/README.md b/README.md
index 872f99144..23cb6784d 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
A free, open-source desktop app for recording your screen and turning the result into polished product demos and walkthroughs.
-
+
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index dcbc432cf..b2611d75b 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -1,12 +1,14 @@
# Third-party notices
OpenScreen is MIT licensed (see [LICENSE](LICENSE)). The installers additionally
-bundle the pre-built native components below. This file ships inside the
+bundle the third-party components below. This file ships inside the
application resources and satisfies the attribution and source-offer obligations
that come with them.
npm dependencies are not listed here: they are resolved from `package.json` and
-distributed by their own registries, not redistributed inside our binaries.
+distributed by their own registries, not redistributed inside our binaries. The
+exception is code bundled into the renderer whose licence asks for its notice in
+the distributed form (js-aruco2 below): the bundler strips the source headers.
---
@@ -38,6 +40,23 @@ distributed by their own registries, not redistributed inside our binaries.
replace them with your own build of the same FFmpeg version by overwriting the
DLLs in `resources/electron/native/bin/win32-*/`.
+## web-demuxer — WebAssembly demuxer
+
+- **Component**: `wasm/web-demuxer.wasm`, inside the application bundle.
+- **Used by**: the editor's streaming decoder, audio waveforms and caption audio
+ extraction, which demux recordings in the renderer with it.
+- **License**: MIT for web-demuxer itself — Copyright (c) 2024 ForeverSc, under the
+ same permission notice as OpenScreen's own [LICENSE](LICENSE). The module also
+ contains FFmpeg code compiled to WebAssembly, under the **GNU Lesser General
+ Public License v2.1 or later**, as web-demuxer's README states.
+- **Upstream**: the npm package `web-demuxer` 4.0.0,
+ . The shipped file is byte-identical
+ to that release's `dist/wasm-files/web-demuxer.wasm`.
+- **Corresponding source**: the web-demuxer repository at the 4.0.0 release, whose
+ `lib/` directory holds the FFmpeg-derived sources and their build scripts.
+- **Replacing it**: the module is a separate file loaded at runtime, so it can be
+ replaced with your own build of the same version.
+
## whisper.cpp and ggml
- **Components**: `whisper-stt-server` and its ggml backend sidecars, under
@@ -147,6 +166,132 @@ distributed by their own registries, not redistributed inside our binaries.
- IBM Plex Mono 2.3 — Copyright © 2017 IBM Corp. `ofl/ibmplexmono/` of
at `23e54b51dd`.
+## Recordly — helper code
+
+- **Components**: parts of the zoom, motion-smoothing and custom-cursor code, and a
+ few lines elsewhere, the native capture helpers included.
+- **License**: MIT — Copyright (c) 2026 webadderall, under the same permission
+ notice as OpenScreen's own [LICENSE](LICENSE). Published by Recordly under MIT,
+ before its relicensing in March 2026.
+
+## js-aruco2 — ArUco marker detection
+
+- **Components**: `src/aruco.js` and `src/cv.js` of the npm package `js-aruco2`
+ 2.0.0, compiled into the renderer bundle (`dist/assets/*.js`).
+- **Used by**: the camera calibration dialog, which finds the four printed corner
+ markers in a camera still.
+- **License**: MIT — Copyright (c) 2020 Damiano Falcioni, Copyright (c) 2011 Juan
+ Mellado, under the same permission notice as OpenScreen's own
+ [LICENSE](LICENSE).
+- **Upstream**: , release 2.0.0.
+
+## OpenCV — ArUco 4×4 dictionary codes
+
+- **Component**: the marker codes in js-aruco2's
+ `src/dictionaries/aruco_4x4_1000.js`, compiled into the renderer bundle. They
+ are taken from OpenCV's `predefined_dictionaries.hpp`; the marker sheet the
+ editor prints draws markers 0–3 of this dictionary.
+- **License**: BSD 3-Clause — Copyright (C) 2013, OpenCV Foundation, all rights
+ reserved. Third party copyrights are property of their respective owners.
+
+ > Redistribution and use in source and binary forms, with or without
+ > modification, are permitted provided that the following conditions are met:
+ >
+ > - Redistributions of source code must retain the above copyright notice,
+ > this list of conditions and the following disclaimer.
+ > - Redistributions in binary form must reproduce the above copyright notice,
+ > this list of conditions and the following disclaimer in the documentation
+ > and/or other materials provided with the distribution.
+ > - Neither the names of the copyright holders nor the names of the
+ > contributors may be used to endorse or promote products derived from this
+ > software without specific prior written permission.
+ >
+ > This software is provided by the copyright holders and contributors "as is"
+ > and any express or implied warranties, including, but not limited to, the
+ > implied warranties of merchantability and fitness for a particular purpose
+ > are disclaimed. In no event shall copyright holders or contributors be liable
+ > for any direct, indirect, incidental, special, exemplary, or consequential
+ > damages (including, but not limited to, procurement of substitute goods or
+ > services; loss of use, data, or profits; or business interruption) however
+ > caused and on any theory of liability, whether in contract, strict liability,
+ > or tort (including negligence or otherwise) arising in any way out of the use
+ > of this software, even if advised of the possibility of such damage.
+
+## Rust crates — compiled into the compositor addon
+
+- **Component**: `compositor_view.node`, under
+ `resources/electron/native/bin/-/`.
+- **What is listed**: every third-party crate in the addon's dependency graph in
+ `crates/Cargo.lock`, across all platforms. Build-time crates (procedural macros,
+ build scripts) are included even though none of their code ships.
+- **Licenses**: as each crate publishes them on crates.io. Where a crate offers a
+ choice, OpenScreen uses it under MIT or Apache-2.0. Each crate's licence text is
+ in its source package at the version listed.
+- **MIT OR Apache-2.0** (155): android_system_properties 0.1.5, anyhow 1.0.103,
+ arrayvec 0.7.8, ash 0.38.0+1.3.281, autocfg 1.5.1, bit-set 0.8.0, bit-vec 0.8.0,
+ bitflags 1.3.2, bitflags 2.13.1, bumpalo 3.20.3, cc 1.2.67, cexpr 0.6.0, cfg-if
+ 1.0.4, core-foundation 0.9.4, core-foundation-sys 0.8.7, core-graphics-types
+ 0.1.3, cosmic-text 0.19.0, crc32fast 1.5.0, ctor 0.2.9, document-features
+ 0.2.12, either 1.16.0, equivalent 1.0.2, fdeflate 0.3.7, find-msvc-tools 0.1.9,
+ flate2 1.1.9, font-types 0.11.3, font-types 0.12.2, foreign-types 0.5.0,
+ foreign-types-macros 0.2.4, foreign-types-shared 0.3.1, futures-core 0.3.33,
+ futures-task 0.3.33, futures-util 0.3.33, glob 0.3.3, gpu-alloc 0.6.2,
+ gpu-alloc-types 0.3.1, gpu-allocator 0.27.0, gpu-descriptor 0.3.2,
+ gpu-descriptor-types 0.2.0, hashbrown 0.15.5, hashbrown 0.17.1, heck 0.5.0,
+ image 0.25.10, indexmap 2.14.0, itertools 0.13.0, itoa 1.0.18, jni-sys 0.3.1,
+ jni-sys 0.4.1, jni-sys-macros 0.4.1, js-sys 0.3.103, khronos-egl 6.0.0, libc
+ 0.2.186, linebender_resource_handle 0.1.1, litrs 1.0.0, lock_api 0.4.14, log
+ 0.4.33, matrixmultiply 0.3.11, memmap2 0.9.11, metal 0.29.0, metal 0.31.0,
+ minimal-lexical 0.2.1, naga 24.0.0, ndarray 0.16.1, ndarray 0.17.2, ndk-sys
+ 0.5.0+25.2.9519653, num-complex 0.4.6, num-integer 0.1.47, num-traits 0.2.19,
+ once_cell 1.21.4, ort 2.0.0-rc.13, ort-sys 2.0.0-rc.13, parking_lot 0.12.5,
+ parking_lot_core 0.9.12, paste 1.0.15, pin-project-lite 0.2.17, pkg-config
+ 0.3.33, png 0.18.1, pollster 0.4.0, portable-atomic 1.15.0, portable-atomic-util
+ 0.2.7, presser 0.3.1, prettyplease 0.2.37, proc-macro2 1.0.106, profiling
+ 1.0.18, quote 1.0.46, range-alloc 0.1.5, rangemap 1.7.1, rawpointer 0.2.1,
+ read-fonts 0.37.0, read-fonts 0.41.0, regex 1.13.1, regex-automata 0.4.16,
+ regex-syntax 0.8.11, renderdoc-sys 1.1.0, roxmltree 0.20.0, rustc-hash 1.1.0,
+ rustc-hash 2.1.3, rustversion 1.0.23, scopeguard 1.2.0, semver 1.0.28, serde
+ 1.0.228, serde_core 1.0.228, serde_derive 1.0.228, serde_json 1.0.150, shlex
+ 1.3.0, shlex 2.0.1, skrifa 0.40.0, skrifa 0.44.0, smallvec 1.15.2, smol_str
+ 0.3.6, static_assertions 1.1.0, swash 0.2.10, syn 2.0.119, syn 3.0.3, sys-locale
+ 0.3.2, thiserror 1.0.69, thiserror 2.0.19, thiserror-impl 1.0.69, thiserror-impl
+ 2.0.19, ttf-parser 0.25.1, unicode-bidi 0.3.18, unicode-script 0.5.8,
+ unicode-segmentation 1.13.3, unicode-width 0.1.14, unicode-xid 0.2.6,
+ version_check 0.9.5, wasm-bindgen 0.2.126, wasm-bindgen-futures 0.4.76,
+ wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126,
+ wasm-bindgen-shared 0.2.126, web-sys 0.3.103, wgpu 24.0.5, wgpu-core 24.0.5,
+ wgpu-hal 24.0.4, wgpu-types 24.0.0, windows 0.58.0, windows_aarch64_gnullvm
+ 0.52.6, windows_aarch64_msvc 0.52.6, windows_i686_gnu 0.52.6,
+ windows_i686_gnullvm 0.52.6, windows_i686_msvc 0.52.6, windows_x86_64_gnu
+ 0.52.6, windows_x86_64_gnullvm 0.52.6, windows_x86_64_msvc 0.52.6, windows-core
+ 0.58.0, windows-implement 0.58.0, windows-interface 0.58.0, windows-link 0.2.1,
+ windows-result 0.2.0, windows-strings 0.1.0, windows-sys 0.61.2, windows-targets
+ 0.52.6, yazi 0.2.1, zeno 0.3.3.
+- **MIT** (26): block 0.1.6, cfg_aliases 0.2.2, convert_case 0.6.0, core_maths
+ 0.1.1, fontconfig-parser 0.5.8, fontdb 0.23.0, harfrust 0.5.2, libm 0.2.16,
+ malloc_buf 0.0.6, napi 2.16.17, napi-build 2.3.2, napi-derive 2.16.13,
+ napi-derive-backend 1.0.75, napi-sys 2.4.0, nom 7.1.3, objc 0.2.7, ordered-float
+ 4.6.0, redox_syscall 0.5.18, simd-adler32 0.3.10, slab 0.4.12, strum 0.26.3,
+ strum_macros 0.26.4, tracing 0.1.44, tracing-core 0.1.36, xml-rs 0.8.28, zmij
+ 1.0.23.
+- **MIT OR Apache-2.0 OR Zlib** (9): bytemuck 1.25.1, bytemuck_derive 1.11.0,
+ glow 0.16.0, miniz_oxide 0.8.9, raw-window-handle 0.6.2, tinyvec 1.12.0,
+ tinyvec_macros 0.1.1, zune-core 0.5.1, zune-jpeg 0.5.15.
+- **Apache-2.0** (7): clang-sys 1.8.1, codespan-reporting 0.11.1, gl_generator
+ 0.14.0, glutin_wgl_sys 0.6.1, khronos_api 3.1.0, spirv 0.3.0+sdk-1.3.268.0,
+ unicode-linebreak 0.1.5.
+- **Unlicense OR MIT** (5): aho-corasick 1.1.4, byteorder-lite 0.1.0, memchr
+ 2.8.3, termcolor 1.4.1, winapi-util 0.1.11.
+- **Zlib** (2): foldhash 0.1.5, slotmap 1.1.1.
+- **ISC** (2): libloading 0.8.9, libloading 0.9.0.
+- **BSD-3-Clause OR Apache-2.0** (2): moxcms 0.8.1, pxfm 0.1.30.
+- **0BSD OR MIT OR Apache-2.0** (1): adler2 2.0.1.
+- **BSD-3-Clause** (1): bindgen 0.70.1.
+- **CC0-1.0** (1): hexf-parse 0.2.1.
+- **Apache-2.0 OR GPL-2.0-only** (1): self_cell 1.3.0.
+- **(MIT OR Apache-2.0) AND Unicode-3.0** (1): unicode-ident 1.0.24.
+
## OpenScreen native helpers
`wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS),
diff --git a/crates/Cargo.toml b/crates/Cargo.toml
index 3caa7edfd..a65f9aaef 100644
--- a/crates/Cargo.toml
+++ b/crates/Cargo.toml
@@ -68,6 +68,7 @@ features = [
"Win32_Graphics_Direct2D_Common", # D2D1_COLOR_F / PIXEL_FORMAT
"Win32_Graphics_DirectWrite", # mise en page + rastérisation du texte
"Win32_Graphics_Gdi", # brosses/police GUI de la preview (poc-d3d/src/app.rs)
+ "Win32_Security", # IDXGIResource1::CreateSharedHandle (shared_frames.rs)
"Win32_System_Performance", # QueryPerformanceCounter / Frequency (§10)
"Win32_System_LibraryLoader", # GetModuleHandleW (hInstance)
"Win32_UI_WindowsAndMessaging", # fenêtres (harnais live + GUI du POC)
diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs
index 8701c8bfe..5a1ded0e1 100644
--- a/crates/compositor-view-napi/src/lib.rs
+++ b/crates/compositor-view-napi/src/lib.rs
@@ -11,10 +11,11 @@ use napi::{Env, JsFunction, Task};
use napi_derive::napi;
use openscreen_compositor::compositor::{live_params_from_scene, Compositor};
use openscreen_compositor::d3d::{Backend, Gpu};
+use openscreen_compositor::frame_geometry::FootageQuad;
use openscreen_compositor::gif_export::{GifExportParams, GifStats};
use openscreen_compositor::gif_export_control::{GifExportCancelled, GifExportControl};
use openscreen_compositor::live::{LiveView, PausedPreviews};
-use openscreen_compositor::scene::Scene;
+use openscreen_compositor::scene::{Scene, SceneClipCamera};
use openscreen_compositor::{config, pipeline};
use std::collections::HashMap;
use std::path::PathBuf;
@@ -197,6 +198,16 @@ pub struct FramePacket {
/// R,G,B,A tightly-packed, `width * height * 4` octets — ce que `putImageData` /
/// `ImageData` attendent côté JS (canvas 2D, format natif RGBA8).
pub data: Buffer,
+ /// Le métrage dans cette image : ses coins TL, TR, BR, BL (x, y en fractions de l'image,
+ /// huit nombres), là où l'éditeur pose le gimbal d'un flou. Absent avant toute composition.
+ pub footage: Option>,
+ /// Le métrage passe de ses coins à l'image par leur homographie (caméra réelle), pas par
+ /// leur interpolation bilinéaire.
+ pub footage_projective: bool,
+ /// Clip actif de la scène quand la frame a été composée (`FramePosition`).
+ pub clip_index: u32,
+ /// Temps source de la frame écran composée, en secondes.
+ pub source_time_sec: f64,
}
/// Renvoie la dernière frame readback du thread de rendu SI elle est plus récente que
@@ -236,17 +247,95 @@ pub fn read_frame(id: i32, since_gen: f64) -> Result> {
v.latest_frame_since(since_gen.max(0.0) as u64)
}
};
- Ok(slot.map(|(gen, w, h, pixels)| {
+ Ok(slot.map(|(gen, w, h, pixels, footage, position)| {
debug_assert_eq!(pixels.len(), (w as usize) * (h as usize) * 4);
FramePacket {
gen: gen as f64,
width: w,
height: h,
data: Buffer::from(pixels),
+ footage: footage_corners(footage),
+ footage_projective: footage.is_some_and(|q| q.projective),
+ clip_index: position.clip_index,
+ source_time_sec: position.source_time_sec,
}
}))
}
+/// Les coins du métrage (TL, TR, BR, BL) aplatis en huit nombres, tels que JS les lit.
+fn footage_corners(footage: Option) -> Option> {
+ footage.map(|q| q.corners.iter().flatten().map(|&v| v as f64).collect())
+}
+
+/// Une frame de preview posée dans une texture partagée : de quoi l'importer côté GPU
+/// (`sharedTexture.importSharedTexture`) au lieu d'en recevoir les pixels.
+#[napi(object)]
+pub struct SharedFramePacket {
+ pub gen: f64,
+ /// Case de l'anneau qui porte la frame, à rendre avec `gen` par `release_shared_frame`
+ /// quand Chromium l'a relâchée.
+ pub slot: u32,
+ /// Handle NT de la texture, 8 octets little-endian : la forme qu'attend Electron
+ /// (`SharedTextureHandle.ntHandle`). Valable dans ce processus seulement.
+ pub handle: Buffer,
+ pub width: u32,
+ pub height: u32,
+ pub footage: Option>,
+ pub footage_projective: bool,
+ pub clip_index: u32,
+ pub source_time_sec: f64,
+}
+
+/// Livrer les frames de la vue par textures partagées plutôt que par `read_frame`. Rend
+/// `false` quand la machine ne le peut pas (hors Windows, backend logiciel) : la vue reste
+/// alors au readback.
+#[napi]
+pub fn set_shared_frames(id: i32, enabled: bool) -> bool {
+ registry()
+ .lock()
+ .unwrap()
+ .get(&id)
+ .is_some_and(|v| v.set_shared_frames(enabled))
+}
+
+/// Le pendant de `read_frame` pour une vue en textures partagées : la dernière frame posée
+/// dans l'anneau, si elle est plus récente que `since_gen`. Sa case reste tenue jusqu'à
+/// `release_shared_frame`. `Ok(None)` aussi quand la vue relit en RAM.
+#[napi]
+pub fn read_shared_frame(id: i32, since_gen: f64) -> Result> {
+ let frame = match registry().lock().unwrap().get(&id) {
+ None => return Ok(None),
+ Some(v) => {
+ // Même relais que `read_frame` : sans lui, un thread de rendu mort ne se verrait
+ // que par un canvas noir.
+ if let Some(fatal) = v.fatal_error() {
+ return Err(Error::from_reason(fatal));
+ }
+ v.take_shared_frame(since_gen.max(0.0) as u64)
+ }
+ };
+ Ok(frame.map(|f| SharedFramePacket {
+ gen: f.gen as f64,
+ slot: f.slot,
+ handle: f.handle.to_le_bytes().to_vec().into(),
+ width: f.width,
+ height: f.height,
+ footage: footage_corners(f.footage),
+ footage_projective: f.footage.is_some_and(|q| q.projective),
+ clip_index: f.position.clip_index,
+ source_time_sec: f.position.source_time_sec,
+ }))
+}
+
+/// Chromium a relâché la frame `gen` de la case `slot` (`allReferencesReleased` côté
+/// Electron) : le thread de rendu peut y réécrire. Sans effet sur une vue détruite.
+#[napi]
+pub fn release_shared_frame(id: i32, slot: u32, gen: f64) {
+ if let Some(v) = registry().lock().unwrap().get(&id) {
+ v.release_shared_frame(slot, gen.max(0.0) as u64);
+ }
+}
+
/// Param live (inspector). Le type de valeur route vers le bon setter :
/// bool = switch (webcamMirror…), number = slider (shadow/roundness/motionBlur/backgroundBlur),
/// string = sélection (backgroundColor "#rrggbb").
@@ -279,6 +368,9 @@ pub fn present_time(id: i32, seconds: f64) {
/// Remplace les sources du clip actif sans recréer la vue ni son thread de rendu. L'identité
/// timeline et le playhead source sont atomiques avec le switch : deux clips partageant les
/// mêmes fichiers restent distincts, et les deux décodeurs ouvrent directement la bonne frame.
+///
+/// `additional_cameras`: cameras 2-4 of the clip (index k-1 = camera k, an empty `path` = no
+/// camera in that slot). Absent = none; the view decodes only those its layout regions show.
#[napi]
pub fn set_active_clip(
id: i32,
@@ -287,18 +379,33 @@ pub fn set_active_clip(
webcam_offset_sec: f64,
clip_index: u32,
source_time_sec: f64,
+ additional_cameras: Option>,
) {
if let Some(v) = registry().lock().unwrap().get(&id) {
+ let cameras = additional_cameras
+ .unwrap_or_default()
+ .into_iter()
+ .map(|c| SceneClipCamera { path: c.path, offset_sec: c.offset_sec })
+ .collect();
v.set_active_clip(
&screen_path,
&webcam_path,
webcam_offset_sec,
+ cameras,
clip_index as usize,
source_time_sec,
);
}
}
+/// An additional camera of a clip (= TS `CompositorClipCamera`).
+#[napi(object)]
+pub struct ClipCameraInput {
+ pub path: String,
+ /// Camera source time = screen source time - this.
+ pub offset_sec: f64,
+}
+
/// Installe la scène de l'app (JSON `SceneDescription`) sur la vue : layout preset piloté par
/// l'app au lieu de la fixture. JSON invalide → ignoré côté natif.
#[napi]
@@ -415,6 +522,26 @@ pub struct ClipInput {
pub webcam_offset_sec: f64,
/// `false` évite une ouverture ffmpeg vouée à échouer et réserve du silence à ce clip.
pub has_audio: bool,
+ /// Cameras 2-4 of the clip (index k-1 = camera k, an empty `path` = none). Absent = none.
+ pub additional_cameras: Option>,
+}
+
+/// `ClipInput` → `pipeline::ClipSource`, shared by the MP4 and the GIF export.
+fn clip_source(c: ClipInput) -> pipeline::ClipSource {
+ pipeline::ClipSource {
+ screen: c.screen_path,
+ webcam: c.webcam_path,
+ source_start_sec: c.source_start_sec,
+ source_end_sec: c.source_end_sec,
+ webcam_offset_sec: c.webcam_offset_sec,
+ has_audio: c.has_audio,
+ additional_cameras: c
+ .additional_cameras
+ .unwrap_or_default()
+ .into_iter()
+ .map(|cam| pipeline::ClipCamera { path: cam.path, offset_sec: cam.offset_sec })
+ .collect(),
+ }
}
/// The export's pixel size. The SHAPE belongs to the scene: `scene.output` is what the live
@@ -612,17 +739,7 @@ pub fn export_multi(
params: Option,
on_progress: Option,
) -> Result> {
- let clips = clips
- .into_iter()
- .map(|c| pipeline::ClipSource {
- screen: c.screen_path,
- webcam: c.webcam_path,
- source_start_sec: c.source_start_sec,
- source_end_sec: c.source_end_sec,
- webcam_offset_sec: c.webcam_offset_sec,
- has_audio: c.has_audio,
- })
- .collect();
+ let clips = clips.into_iter().map(clip_source).collect();
Ok(AsyncTask::new(ExportMultiTask {
out_path,
clips,
@@ -793,17 +910,7 @@ pub fn export_gif(
// Deliberately the same argument shape as `export_multi`: the caller builds
// one clip list and one scene, and picks the container. Cursor comes from
// the scene like every other effect — there is no GIF-specific input left.
- let clips = clips
- .into_iter()
- .map(|c| pipeline::ClipSource {
- screen: c.screen_path,
- webcam: c.webcam_path,
- source_start_sec: c.source_start_sec,
- source_end_sec: c.source_end_sec,
- webcam_offset_sec: c.webcam_offset_sec,
- has_audio: c.has_audio,
- })
- .collect();
+ let clips = clips.into_iter().map(clip_source).collect();
let gif_params = params
.map(|p| GifExportParams {
width: p.width,
diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs
index 66f465153..c55d8ee38 100644
--- a/crates/compositor/build.rs
+++ b/crates/compositor/build.rs
@@ -191,7 +191,7 @@ fn main() {
fn compile_hlsl(out: &Path) {
use windows::core::PCSTR;
use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3};
- use windows::Win32::Graphics::Direct3D::ID3DBlob;
+ use windows::Win32::Graphics::Direct3D::{ID3DBlob, D3D_SHADER_MACRO};
fn blob_bytes(blob: &ID3DBlob) -> Vec {
unsafe {
@@ -203,17 +203,25 @@ fn compile_hlsl(out: &Path) {
const SRC: &str = "src/shaders.hlsl";
println!("cargo:rerun-if-changed={SRC}");
let hlsl = std::fs::read(SRC).expect(SRC);
- // Tous les points d'entrée que `Compositor::new_inner` instancie, avec leur profil.
- for (entry, profile) in [
- ("vs_main", "vs_5_0"),
- ("ps_main", "ps_5_0"),
- ("vs_fs", "vs_5_0"),
- ("ps_y", "ps_5_0"),
- ("ps_uv", "ps_5_0"),
- ("ps_blur", "ps_5_0"),
- ("ps_tex", "ps_5_0"),
- ("ps_kawase_down", "ps_5_0"),
- ("ps_kawase_up", "ps_5_0"),
+ // `ps_main` avec les modèles 3D : la variante que seuls ces calques utilisent (le haut de
+ // `shaders.hlsl`). La liste se termine par une entrée nulle.
+ let models = [
+ D3D_SHADER_MACRO { Name: PCSTR(b"LAYER_MODELS\0".as_ptr()), Definition: PCSTR(b"1\0".as_ptr()) },
+ D3D_SHADER_MACRO { Name: PCSTR::null(), Definition: PCSTR::null() },
+ ];
+ // Tous les points d'entrée que `Compositor::new_inner` instancie, avec leur profil, le nom du
+ // `.cso` qu'en lit `shader!` et leurs macros.
+ for (entry, profile, cso, defines) in [
+ ("vs_main", "vs_5_0", "vs_main", None),
+ ("ps_main", "ps_5_0", "ps_main", None),
+ ("ps_main", "ps_5_0", "ps_main_models", Some(models.as_ptr())),
+ ("vs_fs", "vs_5_0", "vs_fs", None),
+ ("ps_y", "ps_5_0", "ps_y", None),
+ ("ps_uv", "ps_5_0", "ps_uv", None),
+ ("ps_blur", "ps_5_0", "ps_blur", None),
+ ("ps_tex", "ps_5_0", "ps_tex", None),
+ ("ps_kawase_down", "ps_5_0", "ps_kawase_down", None),
+ ("ps_kawase_up", "ps_5_0", "ps_kawase_up", None),
] {
let (entry_z, profile_z) = (format!("{entry}\0"), format!("{profile}\0"));
let (mut code, mut errors): (Option, Option) = (None, None);
@@ -222,7 +230,7 @@ fn compile_hlsl(out: &Path) {
hlsl.as_ptr().cast(),
hlsl.len(),
PCSTR(b"shaders.hlsl\0".as_ptr()),
- None,
+ defines,
None,
PCSTR(entry_z.as_ptr()),
PCSTR(profile_z.as_ptr()),
@@ -234,9 +242,9 @@ fn compile_hlsl(out: &Path) {
};
if let Err(e) = compiled {
let log = errors.as_ref().map(blob_bytes).unwrap_or_default();
- panic!("{SRC} : {entry} ne compile pas ({e})\n{}", String::from_utf8_lossy(&log));
+ panic!("{SRC} : {cso} ne compile pas ({e})\n{}", String::from_utf8_lossy(&log));
}
- std::fs::write(out.join(format!("{entry}.cso")), blob_bytes(&code.unwrap())).expect(entry);
+ std::fs::write(out.join(format!("{cso}.cso")), blob_bytes(&code.unwrap())).expect(cso);
}
}
diff --git a/crates/compositor/examples/live_free_run_bench_macos.rs b/crates/compositor/examples/live_free_run_bench_macos.rs
new file mode 100644
index 000000000..8b3ec5c3b
--- /dev/null
+++ b/crates/compositor/examples/live_free_run_bench_macos.rs
@@ -0,0 +1,125 @@
+//! Bench de la lecture libre de la preview (macOS) : rejoue la boucle de `render_thread` sur
+//! un seul clip, à une vitesse donnée, et mesure le retard que la vue prend sur l'horloge.
+//!
+//! Même arithmétique que `render_thread` : l'accumulateur avance de `dt × vitesse`, `step()`
+//! compose chaque frame due jusqu'à `max_steps` par tick (au-delà le reliquat est abandonné),
+//! puis relit le RT UNE fois par tick, comme la publication macOS (pas de textures partagées).
+//! `dt` est borné à 0,1 s comme dans `render_thread` : un tick plus long perd de l'horloge.
+//!
+//! Utilisation :
+//! `cargo run --release --example live_free_run_bench_macos -- [vitesse=4] [secondes=8] [largeur=1920] [hauteur=1080]`
+
+#[cfg(target_os = "macos")]
+fn main() -> anyhow::Result<()> {
+ use openscreen_compositor::compositor::Compositor;
+ use openscreen_compositor::config;
+ use openscreen_compositor::d3d::Gpu;
+ use openscreen_compositor::frame_geometry::live_params_from_scene;
+ use openscreen_compositor::live::Player;
+ use openscreen_compositor::scene::Scene;
+ use std::time::{Duration, Instant};
+
+ let mut args = std::env::args().skip(1);
+ let path = args.next().unwrap_or_else(|| {
+ eprintln!("usage: live_free_run_bench_macos [vitesse=4] [secondes=8] [largeur=1920] [hauteur=1080]");
+ std::process::exit(2);
+ });
+ let speed: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(4.0);
+ let seconds: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(8.0);
+ let width: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1920);
+ let height: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1080);
+
+ let source = path.replace('\\', "/");
+ let scene_json = format!(
+ r##"{{
+ "clips": [{{"screenPath":"{source}","webcamPath":"","sourceStartSec":0,"sourceEndSec":100000,"webcamOffsetSec":0,"hasAudio":false}}],
+ "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},
+ "effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}},
+ "background": {{"kind":"color","color":"#303030"}},
+ "zoomRegions": [],
+ "speedRegions": [],
+ "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},
+ "cropByClip": [null],
+ "output": {{"width":{width},"height":{height},"fps":30}}
+ }}"##
+ );
+
+ let gpu = Gpu::create(false)?;
+ let mut cfg = config::all().pop().expect("au moins une config");
+ cfg.zoom = false;
+ cfg.layout_anim = false;
+ let comp = Compositor::new_sized(&gpu, width, height)?;
+ let scene = Scene::from_json(&scene_json)?;
+ comp.set_live_params(live_params_from_scene(&scene));
+ comp.set_scene(Some(scene.clone()));
+ comp.clear_cursor();
+
+ unsafe {
+ let mut player = Player::open(&path, "", &gpu)?;
+ player.set_programme_clock(Some(&scene), 0);
+ anyhow::ensure!(player.present_frame(&comp, &cfg, 0.0)?, "aucune frame à 0 s");
+ // Temps source réellement joué, cumulé frame par frame : un clip plus court que la mesure
+ // reboucle à 0 (EOF), et la seule position finale compterait alors une seule passe.
+ let mut played = 0.0f64;
+
+ let mut acc = 0.0f64;
+ let mut last = Instant::now();
+ let t0 = last;
+ let (mut ticks, mut adopted, mut capped, mut published) = (0u64, 0u64, 0u64, 0u64);
+ // Ce que l'horloge de l'app a parcouru, en temps source : la cible que la vue devrait tenir.
+ let mut clock_source = 0.0f64;
+ while t0.elapsed().as_secs_f64() < seconds {
+ let now = Instant::now();
+ let dt = (now - last).as_secs_f64().min(0.1);
+ last = now;
+ ticks += 1;
+ acc += dt * speed;
+ clock_source += (now - t0).as_secs_f64() * speed - clock_source;
+ let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64);
+ let mut n = 0;
+ let mut stepped = false;
+ loop {
+ let before = player.screen_time_sec();
+ if !player.step(&comp, &cfg, before + acc)? {
+ break;
+ }
+ stepped = true;
+ adopted += 1;
+ let after = player.screen_time_sec();
+ played += if after >= before { after - before } else { after };
+ acc = if after >= before { (acc - (after - before)).max(0.0) } else { 0.0 };
+ n += 1;
+ if n >= max_steps {
+ capped += 1;
+ acc = 0.0;
+ break;
+ }
+ }
+ if stepped {
+ comp.readback_direct()?;
+ published += 1;
+ } else {
+ std::thread::sleep(Duration::from_millis(4));
+ }
+ }
+ let wall = t0.elapsed().as_secs_f64();
+ println!(
+ "vitesse {speed}x, {width}x{height}, {wall:.2} s : horloge {:.2} s source, vue {played:.2} s source, retard {:.2} s source ({:.2} s de lecture)",
+ clock_source,
+ clock_source - played,
+ (clock_source - played) / speed
+ );
+ println!(
+ "ticks {ticks} ({:.1}/s), frames adoptées {adopted} ({:.1}/s), plafond atteint {capped} fois, publiées {published} ({:.1}/s)",
+ ticks as f64 / wall,
+ adopted as f64 / wall,
+ published as f64 / wall
+ );
+ }
+ Ok(())
+}
+
+#[cfg(not(target_os = "macos"))]
+fn main() {
+ eprintln!("live_free_run_bench_macos : macOS uniquement");
+}
diff --git a/crates/compositor/src/camera_layers.rs b/crates/compositor/src/camera_layers.rs
new file mode 100644
index 000000000..ed5447b97
--- /dev/null
+++ b/crates/compositor/src/camera_layers.rs
@@ -0,0 +1,476 @@
+//! Which camera layers a frame draws, and where, inside camera layout regions.
+//!
+//! Pure planning: `plan_frame` calls `camera_layers_at` and the backends draw the result. A
+//! region's layers glide in from whatever was on screen before it (the default camera-1 PiP,
+//! or the layers of a region that ends exactly where it starts) and glide back out to the
+//! default, on the Full Camera envelope (`regions::camera_fullscreen_region_phase`): the same
+//! window lengths, the same `ease_out_screen_studio` curve, measured on the screen clock. A
+//! camera-1 Full Camera region that meets a layout region at a seam is a neighbour like any
+//! other: camera 0 frame-filling, gliding straight into or out of the layout region's layers.
+
+use crate::frame_geometry::webcam_shape_code;
+use crate::regions::{
+ ease_out_screen_studio, ScreenClock, FULLSCREEN_LEAD_OUT_WINDOW_S, REGION_SEAM_S,
+ TRANSITION_WINDOW_S,
+};
+use crate::scene::{SceneCameraFullscreenRegion, SceneCameraLayer, SceneCameraLayoutRegion};
+
+/// Two regions closer than this (source seconds) hand over directly, without the default.
+const ADJACENT_S: f64 = REGION_SEAM_S;
+/// Layers fainter than this are not drawn at all.
+const MIN_OPACITY: f32 = 1e-3;
+/// Cameras beyond camera 0 a compositor takes frames for (`set_extra_camera_frames`).
+pub const MAX_EXTRA_CAMERAS: usize = 3;
+
+/// One camera to draw this frame.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct CameraLayerPlan {
+ pub camera: usize,
+ /// x, y, w, h in output fractions.
+ pub dst: [f32; 4],
+ /// Corner radius as a fraction of min(dst w, h) in pixels.
+ pub radius_frac: f32,
+ /// `webcam_shape_code` vocabulary.
+ pub shape: u32,
+ /// 0..1.
+ pub opacity: f32,
+ pub fills_frame: bool,
+}
+
+fn plan_of(layer: &SceneCameraLayer) -> CameraLayerPlan {
+ let r = layer.rect;
+ CameraLayerPlan {
+ camera: layer.camera,
+ dst: [r.x, r.y, r.width, r.height],
+ radius_frac: layer.radius_frac,
+ shape: webcam_shape_code(&layer.shape),
+ opacity: 1.0,
+ fills_frame: layer.fills_frame,
+ }
+}
+
+fn lerp(a: f32, b: f32, k: f32) -> f32 {
+ a + (b - a) * k
+}
+
+/// `a` turning into `b` at `k` (0 = all `a`, 1 = all `b`). A camera in both moves; a camera in
+/// only one of them fades. Draw order: frame-filling layers first, then the rest, each group in
+/// `b`'s order followed by what only `a` still shows.
+fn blend(a: &[CameraLayerPlan], b: &[CameraLayerPlan], k: f32) -> Vec {
+ let k = k.clamp(0.0, 1.0);
+ let mut out = Vec::with_capacity(a.len() + b.len());
+ for to in b {
+ out.push(match a.iter().find(|from| from.camera == to.camera) {
+ Some(from) => {
+ // Discrete properties switch half-way through the move.
+ let flags = if k >= 0.5 { to } else { from };
+ CameraLayerPlan {
+ camera: to.camera,
+ dst: std::array::from_fn(|i| lerp(from.dst[i], to.dst[i], k)),
+ radius_frac: lerp(from.radius_frac, to.radius_frac, k),
+ shape: flags.shape,
+ opacity: 1.0,
+ fills_frame: flags.fills_frame,
+ }
+ }
+ None => CameraLayerPlan { opacity: k, ..*to },
+ });
+ }
+ for from in a {
+ if !b.iter().any(|to| to.camera == from.camera) {
+ out.push(CameraLayerPlan { opacity: 1.0 - k, ..*from });
+ }
+ }
+ out.retain(|layer| layer.opacity > MIN_OPACITY);
+ // Stable: each group keeps the order built above.
+ out.sort_by_key(|layer| !layer.fills_frame);
+ out
+}
+
+/// Camera 0 as a Full Camera region shows it: the whole frame, square corners. Same discrete
+/// shape as the default layer, so the hand-over to the default inside the region is seamless.
+fn full_camera_layer(default: &CameraLayerPlan) -> CameraLayerPlan {
+ CameraLayerPlan {
+ camera: 0,
+ dst: [0.0, 0.0, 1.0, 1.0],
+ radius_frac: 0.0,
+ shape: default.shape,
+ opacity: 1.0,
+ fills_frame: true,
+ }
+}
+
+/// The layers to draw at source time `t`, in draw order.
+///
+/// `default_cam0`: camera 0's layer as today's plan places it (None = camera 0 not drawn). It
+/// is what shows outside every region, and what a region glides from and back to.
+///
+/// `fullscreen`: the camera-1 Full Camera regions. One that meets a layout region at a seam
+/// is that region's neighbour, with camera 0 frame-filling: the layout region glides in from
+/// it, or holds to its end and the Full Camera region's lead-in glides on from its layers
+/// (the Full Camera phase holds 1 on that side, `regions::camera_fullscreen_region_phase`).
+///
+/// Time base: copied from `camera_fullscreen_region_phase` — the region is found on SOURCE
+/// time (its bounds compared to `t` as `f32`), then the windows are measured on the SCREEN
+/// clock (`clock.at`), so a speed region does not stretch or squash the glide.
+pub fn camera_layers_at(
+ regions: &[SceneCameraLayoutRegion],
+ fullscreen: &[SceneCameraFullscreenRegion],
+ t: f32,
+ clock: &ScreenClock,
+ default_cam0: Option,
+) -> Vec {
+ let default: Vec = default_cam0.into_iter().collect();
+ let full_cam0: Vec = default_cam0.iter().map(full_camera_layer).collect();
+ // An empty (or reversed) region would match `t` at a single instant and draw its layers
+ // at full strength for that sample; it is neither drawn nor anyone's neighbour.
+ let non_empty = || regions.iter().enumerate().filter(|(_, r)| r.end_sec > r.start_sec);
+ let full_cameras = || fullscreen.iter().filter(|r| r.end_sec > r.start_sec);
+ let layers_of = |r: &SceneCameraLayoutRegion| r.layers.iter().map(plan_of).collect::>();
+ let Some(index) = non_empty()
+ .find(|(_, r)| r.start_sec as f32 <= t && t <= r.end_sec as f32)
+ .map(|(i, _)| i)
+ else {
+ return full_camera_lead_in(regions, fullscreen, t, clock, &full_cam0).unwrap_or(default);
+ };
+ let region = ®ions[index];
+ let others = || non_empty().filter(move |(i, _)| *i != index).map(|(_, r)| r);
+ let prev = others()
+ .find(|r| (r.end_sec - region.start_sec).abs() <= ADJACENT_S)
+ .map(layers_of)
+ .or_else(|| {
+ full_cameras()
+ .any(|f| (f.end_sec - region.start_sec).abs() <= ADJACENT_S)
+ .then(|| full_cam0.clone())
+ });
+ let has_next = others().any(|r| (r.start_sec - region.end_sec).abs() <= ADJACENT_S)
+ || full_cameras().any(|f| (f.start_sec - region.end_sec).abs() <= ADJACENT_S);
+ let current = layers_of(region);
+
+ let (start, end, t) = (
+ clock.at(region.start_sec as f32),
+ clock.at(region.end_sec as f32),
+ clock.at(t),
+ );
+ let half = (end - start) * 0.5;
+ let win_in = TRANSITION_WINDOW_S.min(half);
+ let win_out = FULLSCREEN_LEAD_OUT_WINDOW_S.min(half);
+ if t - start < win_in {
+ let from = prev.as_deref().unwrap_or(&default);
+ blend(from, ¤t, ease_out_screen_studio((t - start) / win_in))
+ } else if !has_next && end - t < win_out {
+ // With a region right after this one, the hand-over is that region's lead-in instead.
+ blend(¤t, &default, 1.0 - ease_out_screen_studio((end - t) / win_out))
+ } else {
+ blend(&[], ¤t, 1.0)
+ }
+}
+
+/// Inside a Full Camera region that starts where a layout region ends, during its lead-in:
+/// the layout region's layers gliding into camera 0 frame-filling (`full_cam0`), on the same
+/// window and curve as a layout region's lead-in. None anywhere else.
+fn full_camera_lead_in(
+ regions: &[SceneCameraLayoutRegion],
+ fullscreen: &[SceneCameraFullscreenRegion],
+ t: f32,
+ clock: &ScreenClock,
+ full_cam0: &[CameraLayerPlan],
+) -> Option> {
+ let full = fullscreen.iter().find(|f| {
+ f.end_sec > f.start_sec && f.start_sec as f32 <= t && t <= f.end_sec as f32
+ })?;
+ let before = regions.iter().find(|r| {
+ r.end_sec > r.start_sec && (r.end_sec - full.start_sec).abs() <= ADJACENT_S
+ })?;
+ let (start, end) = (clock.at(full.start_sec as f32), clock.at(full.end_sec as f32));
+ let t = clock.at(t);
+ let win_in = TRANSITION_WINDOW_S.min((end - start) * 0.5);
+ if t - start >= win_in {
+ return None;
+ }
+ let from: Vec = before.layers.iter().map(plan_of).collect();
+ Some(blend(&from, full_cam0, ease_out_screen_studio((t - start) / win_in)))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::scene::SceneRect;
+
+ const PIP: [f32; 4] = [0.75, 0.7, 0.22, 0.22];
+ const CORNER: [f32; 4] = [0.05, 0.05, 0.2, 0.2];
+ const FULL: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
+
+ fn default_pip() -> Option {
+ Some(CameraLayerPlan {
+ camera: 0,
+ dst: PIP,
+ radius_frac: 0.5,
+ shape: 1,
+ opacity: 1.0,
+ fills_frame: false,
+ })
+ }
+
+ fn region(start: f64, end: f64, layers: &[(usize, [f32; 4], bool)]) -> SceneCameraLayoutRegion {
+ SceneCameraLayoutRegion {
+ clip_index: Some(0),
+ start_sec: start,
+ end_sec: end,
+ layers: layers
+ .iter()
+ .map(|&(camera, r, fills_frame)| SceneCameraLayer {
+ camera,
+ rect: SceneRect { x: r[0], y: r[1], width: r[2], height: r[3] },
+ radius_frac: if fills_frame { 0.0 } else { 0.3 },
+ shape: if fills_frame { "rectangle" } else { "rounded" }.to_string(),
+ fills_frame,
+ })
+ .collect(),
+ }
+ }
+
+ fn at(regions: &[SceneCameraLayoutRegion], t: f32) -> Vec {
+ camera_layers_at(regions, &[], t, &ScreenClock::default(), default_pip())
+ }
+
+ fn layer(layers: &[CameraLayerPlan], camera: usize) -> Option {
+ layers.iter().copied().find(|l| l.camera == camera)
+ }
+
+ fn strictly_between(v: f32, a: f32, b: f32) -> bool {
+ v > a.min(b) && v < a.max(b)
+ }
+
+ #[test]
+ fn outside_every_region_only_the_default_camera_is_drawn() {
+ let regions = [region(2.0, 6.0, &[(1, FULL, true)])];
+ for t in [0.0, 1.9, 6.1, 9.0] {
+ assert_eq!(at(®ions, t), vec![default_pip().unwrap()], "t = {t}");
+ }
+ assert!(camera_layers_at(®ions, &[], 1.0, &ScreenClock::default(), None).is_empty());
+ }
+
+ #[test]
+ fn inside_a_region_after_the_lead_in_its_layers_are_drawn_fully() {
+ let regions = [region(2.0, 6.0, &[(1, FULL, true), (0, CORNER, false)])];
+ let layers = at(®ions, 4.0);
+ assert_eq!(layers.len(), 2);
+ assert_eq!(layers[0].camera, 1);
+ assert_eq!(layers[0].dst, FULL);
+ assert_eq!(layers[0].opacity, 1.0);
+ assert!(layers[0].fills_frame);
+ assert_eq!(layers[1].camera, 0);
+ assert_eq!(layers[1].dst, CORNER);
+ assert_eq!(layers[1].opacity, 1.0);
+ assert_eq!(layers[1].shape, webcam_shape_code("rounded"));
+ assert_eq!(layers[1].radius_frac, 0.3);
+ }
+
+ #[test]
+ fn entering_a_region_glides_camera_0_and_fades_in_a_new_camera() {
+ let regions = [region(2.0, 6.0, &[(1, FULL, true), (0, CORNER, false)])];
+ let layers = at(®ions, 2.0 + TRANSITION_WINDOW_S / 2.0);
+ let cam0 = layer(&layers, 0).expect("camera 0");
+ for i in 0..4 {
+ assert!(strictly_between(cam0.dst[i], PIP[i], CORNER[i]), "dst[{i}] = {}", cam0.dst[i]);
+ }
+ assert_eq!(cam0.opacity, 1.0);
+ let cam1 = layer(&layers, 1).expect("camera 1");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0), "opacity {}", cam1.opacity);
+ assert_eq!(cam1.dst, FULL);
+ // On the region's start the glide has not begun.
+ assert_eq!(at(®ions, 2.0), vec![default_pip().unwrap()]);
+ }
+
+ #[test]
+ fn leaving_a_region_returns_to_the_default() {
+ let regions = [region(2.0, 6.0, &[(1, FULL, true), (0, CORNER, false)])];
+ let win_out = FULLSCREEN_LEAD_OUT_WINDOW_S.min(2.0);
+ let layers = at(®ions, 6.0 - win_out / 2.0);
+ let cam0 = layer(&layers, 0).expect("camera 0");
+ for i in 0..4 {
+ assert!(strictly_between(cam0.dst[i], PIP[i], CORNER[i]), "dst[{i}] = {}", cam0.dst[i]);
+ }
+ let cam1 = layer(&layers, 1).expect("camera 1");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0), "opacity {}", cam1.opacity);
+ // On the region's end it is back to the default, exactly.
+ let end = at(®ions, 6.0);
+ assert_eq!(end.len(), 1);
+ assert_eq!(end[0].camera, 0);
+ for i in 0..4 {
+ assert!((end[0].dst[i] - PIP[i]).abs() < 1e-6);
+ }
+ }
+
+ #[test]
+ fn adjacent_regions_glide_directly() {
+ let regions = [region(2.0, 5.0, &[(1, FULL, true)]), region(5.0, 8.0, &[(2, FULL, true)])];
+ // The end of A keeps A: no lead-out to the default PiP.
+ let before = at(®ions, 5.0 - 0.01);
+ assert_eq!(before.len(), 1);
+ assert_eq!(before[0].camera, 1);
+ assert_eq!(before[0].opacity, 1.0);
+ // B's lead-in hands camera 1 over to camera 2.
+ let during = at(®ions, 5.0 + TRANSITION_WINDOW_S / 2.0);
+ let cam1 = layer(&during, 1).expect("camera 1 fading out");
+ let cam2 = layer(&during, 2).expect("camera 2 fading in");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0));
+ assert!(strictly_between(cam2.opacity, 0.0, 1.0));
+ // Neither region has camera 0, so the default PiP never shows around the seam.
+ let mut t = 4.8f32;
+ while t <= 5.2 {
+ assert!(layer(&at(®ions, t), 0).is_none(), "camera 0 at t = {t}");
+ t += 0.01;
+ }
+ }
+
+ fn full_camera(start: f64, end: f64) -> SceneCameraFullscreenRegion {
+ SceneCameraFullscreenRegion {
+ clip_index: Some(0),
+ start_sec: start,
+ end_sec: end,
+ rotation: 0,
+ mirror: None,
+ full_frame: false,
+ }
+ }
+
+ /// The plan with Full Camera regions, camera 0's default placed as `plan_frame` places it:
+ /// grown from the PiP to the frame by the Full Camera progress.
+ fn at_with_full(
+ regions: &[SceneCameraLayoutRegion],
+ fulls: &[SceneCameraFullscreenRegion],
+ t: f32,
+ ) -> Vec {
+ let clock = ScreenClock::default();
+ let p = crate::regions::camera_fullscreen_progress_at(fulls, t, &clock, regions);
+ let default = CameraLayerPlan {
+ dst: std::array::from_fn(|i| lerp(PIP[i], FULL[i], p)),
+ fills_frame: p >= 1.0,
+ ..default_pip().unwrap()
+ };
+ camera_layers_at(regions, fulls, t, &clock, Some(default))
+ }
+
+ /// Camera 0 on the straight path between two rects: never the default PiP's geometry.
+ fn on_the_path(dst: [f32; 4], a: [f32; 4], b: [f32; 4]) -> bool {
+ (0..4).all(|i| dst[i] >= a[i].min(b[i]) - 1e-5 && dst[i] <= a[i].max(b[i]) + 1e-5)
+ }
+
+ #[test]
+ fn full_camera_then_layout_region_glides_directly() {
+ let regions = [region(5.0, 8.0, &[(1, FULL, true), (0, CORNER, false)])];
+ let fulls = [full_camera(2.0, 5.0)];
+ // The end of the Full Camera region keeps camera 0 on the frame: no shrink.
+ let before = at_with_full(®ions, &fulls, 5.0 - 0.01);
+ assert_eq!(before.len(), 1);
+ assert_eq!(before[0].camera, 0);
+ assert_eq!(before[0].dst, FULL);
+ assert!(before[0].fills_frame);
+ // The layout region's lead-in moves camera 0 from the frame to its corner.
+ let during = at_with_full(®ions, &fulls, 5.0 + TRANSITION_WINDOW_S / 2.0);
+ let cam0 = layer(&during, 0).expect("camera 0");
+ for i in 2..4 {
+ assert!(strictly_between(cam0.dst[i], FULL[i], CORNER[i]), "dst[{i}] = {}", cam0.dst[i]);
+ }
+ let cam1 = layer(&during, 1).expect("camera 1 fading in");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0));
+ // The default PiP never shows around the seam.
+ let mut t = 3.5f32;
+ while t <= 5.0 + TRANSITION_WINDOW_S + 0.1 {
+ let cam0 = layer(&at_with_full(®ions, &fulls, t), 0).expect("camera 0");
+ assert!(on_the_path(cam0.dst, FULL, CORNER), "t = {t}: {:?}", cam0.dst);
+ assert_eq!(cam0.opacity, 1.0, "t = {t}");
+ t += 0.005;
+ }
+ }
+
+ #[test]
+ fn layout_region_then_full_camera_glides_directly() {
+ let regions = [region(2.0, 5.0, &[(1, FULL, true), (0, CORNER, false)])];
+ let fulls = [full_camera(5.0, 8.0)];
+ // The end of the layout region keeps its layers: no lead-out to the default PiP.
+ let before = at_with_full(®ions, &fulls, 5.0 - 0.01);
+ assert_eq!(layer(&before, 0).expect("camera 0").dst, CORNER);
+ assert_eq!(layer(&before, 1).expect("camera 1").opacity, 1.0);
+ // The Full Camera lead-in takes camera 0 from its corner to the frame; camera 1 fades.
+ let during = at_with_full(®ions, &fulls, 5.0 + TRANSITION_WINDOW_S / 2.0);
+ let cam0 = layer(&during, 0).expect("camera 0");
+ for i in 2..4 {
+ assert!(strictly_between(cam0.dst[i], CORNER[i], FULL[i]), "dst[{i}] = {}", cam0.dst[i]);
+ }
+ let cam1 = layer(&during, 1).expect("camera 1 fading out");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0));
+ // After the lead-in camera 0 is the default again, which the held phase keeps full.
+ let after = at_with_full(®ions, &fulls, 5.0 + TRANSITION_WINDOW_S + 0.01);
+ assert_eq!(after.len(), 1);
+ assert_eq!(after[0].dst, FULL);
+ let mut t = 3.5f32;
+ while t <= 7.0 {
+ let cam0 = layer(&at_with_full(®ions, &fulls, t), 0).expect("camera 0");
+ assert!(on_the_path(cam0.dst, CORNER, FULL), "t = {t}: {:?}", cam0.dst);
+ assert_eq!(cam0.opacity, 1.0, "t = {t}");
+ t += 0.005;
+ }
+ }
+
+ #[test]
+ fn a_full_camera_region_off_the_seam_is_no_neighbour() {
+ // 10 ms apart: the layout region glides from the default as before.
+ let regions = [region(5.01, 8.0, &[(1, FULL, true), (0, CORNER, false)])];
+ let fulls = [full_camera(2.0, 5.0)];
+ assert_eq!(
+ at_with_full(®ions, &fulls, 5.01 + TRANSITION_WINDOW_S / 2.0),
+ at(®ions, 5.01 + TRANSITION_WINDOW_S / 2.0)
+ );
+ let shrinking = layer(&at_with_full(®ions, &fulls, 4.9), 0).expect("camera 0");
+ assert!(shrinking.dst[2] < 1.0, "the Full Camera region leads out: {:?}", shrinking.dst);
+ }
+
+ #[test]
+ fn a_short_region_halves_its_windows() {
+ let regions = [region(2.0, 3.0, &[(1, FULL, true)])];
+ // Both windows are 0.5 s: the middle is the only frame at full strength.
+ let mid = at(®ions, 2.5);
+ assert_eq!(mid.len(), 1);
+ assert_eq!(mid[0].camera, 1);
+ assert_eq!(mid[0].opacity, 1.0);
+ for t in [2.25, 2.75] {
+ let cam1 = layer(&at(®ions, t), 1).expect("camera 1");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0), "t = {t}");
+ }
+ }
+
+ #[test]
+ fn an_empty_region_draws_nothing_of_its_own() {
+ // start == end (and a reversed region) would otherwise match `t` at its one instant
+ // and draw its layers at full strength for that sample.
+ for regions in [
+ [region(3.0, 3.0, &[(1, FULL, true)])],
+ [region(3.0, 2.0, &[(1, FULL, true)])],
+ ] {
+ for t in [2.0, 2.5, 3.0] {
+ let layers = at(®ions, t);
+ assert!(layer(&layers, 1).is_none(), "t = {t}");
+ assert_eq!(layers.len(), 1, "t = {t}");
+ assert_eq!(layers[0].camera, 0, "t = {t}");
+ }
+ }
+ // Nor does it count as a neighbour: the region before it still leads out.
+ let regions = [region(2.0, 6.0, &[(1, FULL, true)]), region(6.0, 6.0, &[(1, FULL, true)])];
+ let cam1 = layer(&at(®ions, 5.9), 1).expect("camera 1");
+ assert!(strictly_between(cam1.opacity, 0.0, 1.0));
+ }
+
+ #[test]
+ fn fills_frame_layers_are_drawn_first() {
+ let regions = [region(2.0, 6.0, &[(0, CORNER, false), (1, FULL, true)])];
+ let order: Vec = at(®ions, 4.0).iter().map(|l| l.camera).collect();
+ assert_eq!(order, vec![1, 0]);
+ // During the lead-in too.
+ let order: Vec =
+ at(®ions, 2.0 + TRANSITION_WINDOW_S / 2.0).iter().map(|l| l.camera).collect();
+ assert_eq!(order, vec![1, 0]);
+ }
+}
diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs
index 198e6e637..e9505224c 100644
--- a/crates/compositor/src/compositor_linux.rs
+++ b/crates/compositor/src/compositor_linux.rs
@@ -52,6 +52,12 @@ use crate::scene::{Scene, SceneBackground, WallpaperMotion};
const LAYER_WGSL: &str = include_str!("vk_shaders/layer.wgsl");
const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl");
+/// `layer.wgsl` avec ou sans ses modeles 3D (modes 15 a 17) : la constante que le shader teste
+/// pour les compiler, prefixee au source (pourquoi deux pipelines : le haut de `layer.wgsl`).
+fn layer_source(models: bool) -> String {
+ format!("const LAYER_MODELS: bool = {models};\n{LAYER_WGSL}")
+}
+
/// Budget du cache de textures image (`img_cache`), en octets.
///
/// Doit tenir le JEU ACTIF d'une frame -- au pire un wallpaper d'ecran ET un
@@ -61,16 +67,24 @@ const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl");
/// en parcourant les 18 wallpapers livres) en laissant le jeu actif resident.
const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
-/// Taille du buffer uniforme d'un calque : `LayerCB` entier (176 octets), le `struct Layer` de
+/// Taille du buffer uniforme d'un calque : `LayerCB` entier (256 octets), le `struct Layer` de
/// `layer.wgsl`. `blur.wgsl` n'en lit que les 128 premiers.
const LAYER_BYTES: u64 = std::mem::size_of::() as u64;
/// `&LayerCB` -> ses octets. `LayerCB` est `#[repr(C, align(16))]`, son layout EST le buffer
-/// uniforme WGSL (dix vec4 et un vec2 + 2 f32 = 176 octets).
+/// uniforme WGSL (seize vec4 = 256 octets).
fn layer_bytes(cb: &LayerCB) -> &[u8] {
unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, LAYER_BYTES as usize) }
}
+/// Le maillage de Prism Glow tel que le lit le WGSL (`PrismMesh`) : les triangles, les
+/// polygones, un point par vec4 (le pas d'un tableau uniform), puis les boîtes.
+fn prism_mesh_bytes() -> Vec {
+ use crate::prism_mesh::{BOXES, POLYS, TRIS};
+ let points = POLYS.iter().map(|&[x, y]| [x, y, 0.0, 0.0]);
+ TRIS.iter().copied().chain(points).chain(BOXES).flatten().flat_map(f32::to_ne_bytes).collect()
+}
+
/// Un calque de fond deja lie, en attente de son `draw`. `_buf`/`_tex`/`_view`
/// ne sont jamais relus : ils gardent en vie ce que le bind group reference
/// jusqu'au submit. Ce backend encode toute la frame avant de la soumettre, la
@@ -81,10 +95,25 @@ fn layer_bytes(cb: &LayerCB) -> &[u8] {
/// ET le fond de la bulle webcam sont desormais construits par les memes
/// methodes.
struct BgDraw {
- _buf: wgpu::Buffer,
+ layer: LayerBind,
_tex: Option,
_view: Option,
+}
+
+/// Un calque pret a dessiner : son uniforme (qui doit vivre jusqu'au draw), son bind group, et
+/// s'il est l'un des modeles 3D, que seul `pipeline_models` compile (`LayerCB::needs_models`).
+/// `draw_layer` en tire le pipeline : c'est le mode du calque qui decide, pas le site d'appel.
+struct LayerBind {
+ _buf: wgpu::Buffer,
bind: wgpu::BindGroup,
+ models: bool,
+}
+
+/// Le fond tel que la passe 1 (et son flou) l'a laisse dans le RT, et tout ce qui l'a decide
+/// (`key`, cf. `compose_frame`).
+struct BgCache {
+ key: crate::frame_geometry::BackgroundKey,
+ tex: wgpu::Texture,
}
/// Une copie RT -> staging DEJA SOUMISE, dont le mapping est arme mais pas
@@ -262,16 +291,23 @@ pub struct Compositor {
// Pipeline de calque (VS + FS `layer.wgsl`), sampler lineaire, bind group
// layout (uniform + 2 textures + sampler). Immuables apres `new_sized`.
+ // `pipeline` n'a pas les modeles 3D (modes 15 a 17) : ils passent par `pipeline_models`.
pipeline: wgpu::RenderPipeline,
/// Meme shader et meme layout que `pipeline`, blend ADDITIF pondere par la
/// constante de blend. Sert a sommer les copies de la trainee du curseur
/// dans `accum` ; cf. `blend_add` cote Windows.
pipeline_add: wgpu::RenderPipeline,
+ /// `pipeline` et `pipeline_add` avec les modeles 3D : le curseur modelise, l'impact de son
+ /// clic, l'appareil et son ombre, et rien d'autre (cf. `layer_source`, `draw_layer`).
+ pipeline_models: wgpu::RenderPipeline,
+ pipeline_add_models: wgpu::RenderPipeline,
/// Copie plein ecran d'`accum` vers le RT en « over » premultiplie
/// (`blur.wgsl` : `vs_fullscreen` + `fs_copy`). Utilise le layout du blur.
pipeline_copy: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
+ /// Le maillage de Prism Glow (`prism_mesh.rs`) tel que le lit le WGSL (`PrismMesh`, binding 7).
+ prism_mesh: wgpu::Buffer,
// Chaine de blur Kawase du fond (`blur.wgsl`) : layout dedie (uniform + 1
// tex + sampler), 2 pipelines (down/up), 3 textures de pyramide (1/2, 1/4,
@@ -313,10 +349,15 @@ pub struct Compositor {
readback_yuv: RefCell,
// Etat pilote par live.rs (interior mutability : les methodes sont `&self`).
+ /// The frames of cameras 1..=3 (`set_extra_camera_frames`), as addresses (0 = none) so
+ /// the compositor's auto traits stay what they were without the raw pointers.
+ extra_camera_frames: std::cell::Cell<[usize; crate::camera_layers::MAX_EXTRA_CAMERAS]>,
live_params: RefCell,
scene: RefCell>,
cursor: RefCell >,
cursor_time: RefCell >,
+ /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`).
+ footage: std::cell::Cell >,
timeline_time: RefCell >,
/// Temps programme (secondes de sortie) -- cf. `FrameGeometryInput::programme_time`.
programme_time: RefCell >,
@@ -341,6 +382,9 @@ pub struct Compositor {
/// touche depuis appartient au jeu actif et ne peut pas etre evince -- voir
/// `cached_image`.
img_frame_start: std::cell::Cell,
+ /// Le dernier fond fixe compose. Un fond qui ne bouge pas est le meme a chaque frame : on le
+ /// recopie au lieu de le redessiner (passe 1 de `compose_frame`).
+ bg_cache: RefCell>,
/// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme.
/// Pas d'eviction : seuls les sprites du theme en cours y passent (~2,6 Mo pour les seize).
sdf_cache: RefCell>,
@@ -410,10 +454,14 @@ impl Compositor {
feature_level: gpu.feature_level,
};
- let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
- label: Some("layer.wgsl"),
- source: wgpu::ShaderSource::Wgsl(LAYER_WGSL.into()),
- });
+ let mk_module = |label: &str, models: bool| {
+ gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some(label),
+ source: wgpu::ShaderSource::Wgsl(layer_source(models).into()),
+ })
+ };
+ let module = mk_module("layer.wgsl", false);
+ let module_models = mk_module("layer.wgsl (modeles)", true);
let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("layer"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
@@ -474,26 +522,44 @@ impl Compositor {
// Pyramide de profondeur de champ (modes 8 et 18), `dummy` ailleurs : le
// mode 18 lit a la fois le metrage, son rendu isole (binding 4) et elle.
tex_entry(6),
+ // Le maillage de Prism Glow (mode 15). Un uniform et non des tables dans le
+ // shader : lavapipe les recopiait a chaque pixel de chaque calque, et une
+ // frame sans curseur se rendait 3,4 fois plus lentement.
+ wgpu::BindGroupLayoutEntry {
+ binding: 7,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ },
],
});
+ let prism_mesh = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: Some("prism-mesh"),
+ contents: &prism_mesh_bytes(),
+ usage: wgpu::BufferUsages::UNIFORM,
+ });
let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("layer"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
- // Deux pipelines pour le MEME shader de calque : seul le blend change.
- let mk_layer = |label: &str, blend: wgpu::BlendState| {
+ // Deux pipelines par source de calque : seul le blend change.
+ let mk_layer = |label: &str, module: &wgpu::ShaderModule, blend: wgpu::BlendState| {
gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
- module: &module,
+ module,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
- module: &module,
+ module,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
@@ -512,22 +578,23 @@ impl Compositor {
cache: None,
})
};
- let pipeline = mk_layer("layer", wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
+ let over = wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING;
+ let pipeline = mk_layer("layer", &module, over);
+ let pipeline_models = mk_layer("layer-models", &module_models, over);
// SOMME pondere : `src * constante + dst`. La constante (posee par pass
// via `set_blend_constant`) vaut 1/taps, donc N copies d'un curseur
// parfaitement immobile redonnent exactement ce curseur. Transcription
// du `blend_add` D3D11 (BLEND_FACTOR / ONE / OP_ADD sur couleur ET
// alpha) ; l'alpha doit suivre la couleur, sinon la somme n'est plus
// premultipliee et la composition finale delave la trainee.
- let add = wgpu::BlendComponent {
+ let sum = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Constant,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
};
- let pipeline_add = mk_layer(
- "layer-add",
- wgpu::BlendState { color: add, alpha: add },
- );
+ let add = wgpu::BlendState { color: sum, alpha: sum };
+ let pipeline_add = mk_layer("layer-add", &module, add);
+ let pipeline_add_models = mk_layer("layer-add-models", &module_models, add);
// --- Chaine de blur Kawase du fond (`blur.wgsl`) ---
let blur_module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -660,9 +727,12 @@ impl Compositor {
render_h: h,
pipeline,
pipeline_add,
+ pipeline_models,
+ pipeline_add_models,
pipeline_copy,
bind_group_layout,
sampler,
+ prism_mesh,
blur_bgl,
blur_down,
blur_up,
@@ -678,10 +748,12 @@ impl Compositor {
readback,
yuv: RefCell::new(None),
readback_yuv,
+ extra_camera_frames: std::cell::Cell::new([0; crate::camera_layers::MAX_EXTRA_CAMERAS]),
live_params: RefCell::new(LiveParams::default()),
scene: RefCell::new(None),
cursor: RefCell::new(None),
cursor_time: RefCell::new(None),
+ footage: std::cell::Cell::new(None),
timeline_time: RefCell::new(None),
programme_time: RefCell::new(None),
text_raster: crate::text::TextRasterizer::new().ok(),
@@ -689,6 +761,7 @@ impl Compositor {
sdf_cache: RefCell::new(std::collections::HashMap::new()),
img_tick: std::cell::Cell::new(0),
img_frame_start: std::cell::Cell::new(0),
+ bg_cache: RefCell::new(None),
ann_copy,
ann_copy_view,
ann_copy_mips,
@@ -749,6 +822,7 @@ impl Compositor {
ann_copy,
ann_copy_view,
ann_copy_mips,
+ bg_cache: RefCell::new(None),
..self
})
}
@@ -817,7 +891,8 @@ impl Compositor {
view_formats: &[],
})
};
- let rt = mk("rt", wgpu::TextureUsages::COPY_SRC);
+ // COPY_DST : le fond fixe y est recopie (`bg_cache`).
+ let rt = mk("rt", wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST);
let accum = mk("accum", wgpu::TextureUsages::empty());
let rt_view = rt.create_view(&wgpu::TextureViewDescriptor::default());
let accum_view = accum.create_view(&wgpu::TextureViewDescriptor::default());
@@ -1095,6 +1170,9 @@ impl Compositor {
pub fn set_scene(&self, s: Option) {
*self.scene.borrow_mut() = s;
+ // A new scene can drop the regions that drew an extra camera: forget its frames so a
+ // stale pointer is never drawn before the next `set_extra_camera_frames`.
+ self.extra_camera_frames.set([0; crate::camera_layers::MAX_EXTRA_CAMERAS]);
}
pub fn set_cursor(&self, track: crate::cursor::CursorTrack) {
@@ -1129,8 +1207,29 @@ impl Compositor {
}
/// Pas de cache de SRV cote wgpu (les `TextureView`s sont recreees a chaque
- /// draw depuis le carrier) -- no-op conserve pour la symetrie d'API.
- pub fn clear_srv_cache(&self) {}
+ /// draw depuis le carrier). Only the extra cameras' frame slots are reset.
+ pub fn clear_srv_cache(&self) {
+ // The extra cameras' frames belong to the decoders just closed: forget them too, so
+ // a stale pointer is never read before the next `set_extra_camera_frames`.
+ self.extra_camera_frames.set([0; crate::camera_layers::MAX_EXTRA_CAMERAS]);
+ }
+
+ /// Frames for cameras 1..=3 (index 0 = scene camera 1). A null or missing entry means that
+ /// camera is not drawn this frame. The compositor keeps the pointers and reads them in every
+ /// `compose_frame` until the next call, so they must stay valid until then.
+ pub unsafe fn set_extra_camera_frames(&self, frames: &[*const AVFrame]) {
+ let mut slots = [0usize; crate::camera_layers::MAX_EXTRA_CAMERAS];
+ for (slot, frame) in slots.iter_mut().zip(frames) {
+ *slot = *frame as usize;
+ }
+ self.extra_camera_frames.set(slots);
+ }
+
+ /// The frame set for scene camera `camera` (1..=3), null when there is none.
+ fn extra_camera_frame(&self, camera: usize) -> *const AVFrame {
+ let slots = self.extra_camera_frames.get();
+ camera.checked_sub(1).and_then(|k| slots.get(k).copied()).unwrap_or(0) as *const AVFrame
+ }
// -- seam frame (lit le carrier `data[0]`) --
@@ -1251,24 +1350,16 @@ impl Compositor {
fn draw_screen_group(
&self,
rpass: &mut wgpu::RenderPass<'_>,
- shadow: &Option<(wgpu::Buffer, wgpu::BindGroup)>,
- window_frame: &Option<(wgpu::Buffer, wgpu::BindGroup)>,
- screen: &wgpu::BindGroup,
- device_frame: &Option<(wgpu::Buffer, wgpu::BindGroup)>,
+ shadow: &Option,
+ window_frame: &Option,
+ screen: &LayerBind,
+ device_frame: &Option,
) {
- if let Some((_buf, bind)) = shadow {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
- }
- if let Some((_buf, bind)) = window_frame {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
- }
- rpass.set_bind_group(0, screen, &[]);
- rpass.draw(0..4, 0..1);
- if let Some((_buf, bind)) = device_frame {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
+ for layer in [shadow.as_ref(), window_frame.as_ref(), Some(screen), device_frame.as_ref()]
+ .into_iter()
+ .flatten()
+ {
+ self.draw_layer(rpass, layer, false);
}
}
@@ -1277,12 +1368,13 @@ impl Compositor {
cb: &LayerCB,
planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>,
dummy: &wgpu::TextureView,
- ) -> (wgpu::Buffer, wgpu::BindGroup) {
+ ) -> LayerBind {
self.make_bind_b4(cb, planes, dummy, None, None)
}
/// `make_bind`, binding 4 impose (le rendu isole du mode 18, a la place du masque webcam
/// qu'il ne lit pas), et la pyramide de profondeur de champ en binding 6 (modes 8 et 18).
+ /// Le mode 15 y met le champ de son sprite et le sprite.
fn make_bind_b4(
&self,
cb: &LayerCB,
@@ -1290,7 +1382,7 @@ impl Compositor {
dummy: &wgpu::TextureView,
b4: Option<&wgpu::TextureView>,
dof: Option<&wgpu::TextureView>,
- ) -> (wgpu::Buffer, wgpu::BindGroup) {
+ ) -> LayerBind {
let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("layer-uniform"),
contents: layer_bytes(cb),
@@ -1338,9 +1430,29 @@ impl Compositor {
binding: 6,
resource: wgpu::BindingResource::TextureView(dof.unwrap_or(dummy)),
},
+ wgpu::BindGroupEntry { binding: 7, resource: self.prism_mesh.as_entire_binding() },
],
});
- (uniform, bind)
+ LayerBind { _buf: uniform, bind, models: cb.needs_models() }
+ }
+
+ /// Dessine un calque par le pipeline plat que l'appelant a lie (`pipeline`, ou `pipeline_add`
+ /// si `add`) -- sauf un modele 3D, qui passe le temps de son draw par la variante
+ /// « modeles » de ce pipeline, la seule a le compiler (pourquoi : le haut de `layer.wgsl`).
+ fn draw_layer(&self, rpass: &mut wgpu::RenderPass<'_>, layer: &LayerBind, add: bool) {
+ rpass.set_bind_group(0, &layer.bind, &[]);
+ if layer.models {
+ let (models, flat) = if add {
+ (&self.pipeline_add_models, &self.pipeline_add)
+ } else {
+ (&self.pipeline_models, &self.pipeline)
+ };
+ rpass.set_pipeline(models);
+ rpass.draw(0..4, 0..1);
+ rpass.set_pipeline(flat);
+ } else {
+ rpass.draw(0..4, 0..1);
+ }
}
/// Charge un PNG/JPEG (chemin fichier ou data URI) en texture RGBA8. Port
@@ -1484,7 +1596,8 @@ impl Compositor {
/// son propre repli, et un echec silencieux redonnerait le noir qu'on corrige.
///
/// `motion` anime l'image au temps programme `programme_t` ; la bulle webcam
- /// passe `WallpaperMotion::None`.
+ /// passe `WallpaperMotion::None`. `transparency` = `layer_fx.x` (0 = opaque): a camera
+ /// layer's background box fades with the layer.
#[allow(clippy::too_many_arguments)]
fn image_bg_draw(
&self,
@@ -1495,6 +1608,7 @@ impl Compositor {
aspect: f32,
motion: WallpaperMotion,
programme_t: f32,
+ transparency: f32,
dummy: &wgpu::TextureView,
) -> Result {
let (tex, iw, ih) = self.cached_image(path)?;
@@ -1516,11 +1630,12 @@ impl Compositor {
mode: 6.0,
fx: [0.0, 0.0, anim[0], anim[1]],
mb,
+ layer_fx: [transparency, 0.0, 0.0, 0.0],
..Default::default()
};
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
- let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), dummy);
- Ok(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind })
+ let layer = self.make_bind(&cb, Some((&view, &view, &view)), dummy);
+ Ok(BgDraw { layer, _tex: Some(tex), _view: Some(view) })
}
/// Prepare le fond du mode « personnalise », peint DANS la bulle webcam juste
@@ -1536,25 +1651,27 @@ impl Compositor {
///
/// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit epouser ses
/// coins arrondis, sinon un rectangle deborde derriere la camera.
+ ///
+ /// `transparency` = the camera layer's `layer_fx.x`: the box fades with the camera.
fn webcam_bg_draw(
&self,
bg: Option<&SceneBackground>,
dst: [f32; 4],
quad_px: [f32; 2],
radius_px: f32,
+ transparency: f32,
dummy: &wgpu::TextureView,
) -> BgDraw {
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
- let flat = |cb: LayerCB| {
- let (buf, bind) = self.make_bind(&cb, None, dummy);
- BgDraw { _buf: buf, _tex: None, _view: None, bind }
- };
+ let layer_fx = [transparency, 0.0, 0.0, 0.0];
+ let flat = |cb: LayerCB| BgDraw { layer: self.make_bind(&cb, None, dummy), _tex: None, _view: None };
let solid = |color: [f32; 4]| LayerCB {
dst,
quad_px,
radius_px,
mode: 1.0,
color,
+ layer_fx,
..Default::default()
};
match bg {
@@ -1572,6 +1689,7 @@ impl Compositor {
quad_px,
radius_px,
fx: [a.sin(), -a.cos(), 0.0, 0.0],
+ layer_fx,
..crate::frame_geometry::gradient_layer(stops, offsets, BLACK)
})
}
@@ -1580,7 +1698,10 @@ impl Compositor {
// elle que l'image doit remplir sans etirement.
let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 };
let still = WallpaperMotion::None;
- match self.image_bg_draw(path, dst, quad_px, radius_px, aspect, still, 0.0, dummy) {
+ let drawn = self.image_bg_draw(
+ path, dst, quad_px, radius_px, aspect, still, 0.0, transparency, dummy,
+ );
+ match drawn {
Ok(d) => d,
Err(e) => {
// Meme contrat que le fond d'ecran : un chemin casse est
@@ -1685,7 +1806,7 @@ impl Compositor {
// `color.a = 1` n'est pas decoratif : `fs_main` calcule son alpha en
// `layer.color.a * alpha_mask`, donc le defaut (0) rendrait un quad
// entierement transparent.
- let (_uniform, bind) = self.make_bind(
+ let layer = self.make_bind(
&LayerCB {
dst: [0.0, 0.0, 1.0, 1.0],
src,
@@ -1718,8 +1839,7 @@ impl Compositor {
occlusion_query_set: None,
});
rpass.set_pipeline(&self.pipeline);
- rpass.set_bind_group(0, &bind, &[]);
- rpass.draw(0..4, 0..1);
+ self.draw_layer(&mut rpass, &layer, false);
}
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
@@ -2024,6 +2144,11 @@ impl Compositor {
*self.webcam_mask.borrow_mut() = None;
}
+ /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`).
+ pub fn footage_quad(&self) -> Option {
+ self.footage.get()
+ }
+
/// Rend une frame dans le RT interne. Le screen `screen`/`webcam` sont des
/// carriers `linux_frames` ; la geometrie vient de `plan_frame`. Coeur :
/// fond uni + ecran cover-fit. `readback_direct` lit ensuite le RT.
@@ -2099,6 +2224,7 @@ impl Compositor {
timeline_t_override: *self.timeline_time.borrow(),
programme_time: *self.programme_time.borrow(),
});
+ self.footage.set(Some(g.footage_quad([rw, rh])));
// (`wtw`/`wth` sont les dims de la TEXTURE webcam, consommees par le
// cover-crop du calque PiP plus bas.)
@@ -2210,12 +2336,12 @@ impl Compositor {
g.tilt_pixel_trail([rw, rh]),
),
};
- // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ;
- // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind).
+ // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; `LayerBind`
+ // garde le buffer uniforme en vie (reference par le bind).
let dummy = self.dummy_view();
// Binding 6 du mode 8 : la pyramide si l'effet tourne, sinon `dummy` (`k = 0`, le
// shader n'y lit rien).
- let (_screen_uniform, screen_bind) = self.make_bind_b4(
+ let screen_bind = self.make_bind_b4(
&screen_layer,
Some((&sy, &su, &sv)),
&dummy,
@@ -2291,18 +2417,30 @@ impl Compositor {
)
});
+ // Le fond de la frame d'avant, s'il est celui qu'on demande (`BackgroundKey`) : la passe 1
+ // le recopie au lieu de le redessiner.
+ let bg_desc = scene_ref.as_ref().map(|s| &s.background);
+ let bg_cached = self
+ .bg_cache
+ .borrow()
+ .as_ref()
+ .is_some_and(|c| c.key.matches(bg_desc, cfg.bg_blur, lp.bg_color, [rw, rh]));
+ let bg_wanted = bg_layer.is_some();
+
// Fond (gradient mode 5 OU image mode 6), dessine dans la passe de fond.
- let bg_draw = bg_layer.and_then(|bl| match bl {
+ let bg_draw = bg_layer.filter(|_| !bg_cached).and_then(|bl| match bl {
BgLayer::Gradient(cb) => {
- let (buf, bind) = self.make_bind(&cb, None, &dummy);
- Some(BgDraw { _buf: buf, _tex: None, _view: None, bind })
+ Some(BgDraw { layer: self.make_bind(&cb, None, &dummy), _tex: None, _view: None })
}
// Le wallpaper couvre tout le cadre, donc dst plein et pas de coins :
// `image_bg_draw` sert aussi la bulle webcam, qui elle en a.
BgLayer::Image(path, motion) => {
let full = [0.0, 0.0, 1.0, 1.0];
let t = g.programme_t;
- match self.image_bg_draw(&path, full, [0.0, 0.0], 0.0, rw / rh, motion, t, &dummy) {
+ let drawn = self.image_bg_draw(
+ &path, full, [0.0, 0.0], 0.0, rw / rh, motion, t, 0.0, &dummy,
+ );
+ match drawn {
Ok(d) => Some(d),
Err(e) => {
eprintln!("[fond image] \"{path}\" : {e:#}");
@@ -2334,7 +2472,10 @@ impl Compositor {
// lancement gracieux, le temps que l'inference rende son premier masque.
//
// Calcule ICI, avant le draw comme avant l'ombre : les deux en dependent.
- let (effect_code, blur_intensity, webcam_bg) = {
+ // Without layout regions camera 0 is drawn from `w_dst` (`webcam_bg`, `webcam_draw`,
+ // `webcam_shadow` below); with them every planned layer is (`camera_layer_draws`).
+ let unplanned = g.camera_layers.is_empty();
+ let (effect_code, blur_intensity, custom_bg) = {
let has_mask = self.webcam_mask.borrow().is_some();
let effect = scene_ref
.as_ref()
@@ -2350,24 +2491,16 @@ impl Compositor {
// fond. Le shader ne sait peindre qu'une couleur plate sous le
// masque ; degrades et images y tombaient sur du noir, et le defaut
// EST une image.
- Some((code, e)) if code > 2.5 => {
- // Sans piste webcam le fond peindrait un rectangle seul dans le
- // cadre : il ne se prepare que si la camera se dessine.
- let bg = webcam_planes.is_some().then(|| {
- self.webcam_bg_draw(
- e.background.as_ref(),
- g.w_dst,
- g.w_px,
- g.w_radius,
- &dummy,
- )
- });
- (1.0, 0.0, bg)
- }
+ Some((code, e)) if code > 2.5 => (1.0, 0.0, Some(e.background.as_ref())),
Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), None),
None => (0.0, 0.0, None),
}
};
+ // Sans piste webcam le fond peindrait un rectangle seul dans le cadre : il ne se
+ // prepare que si la camera se dessine.
+ let webcam_bg = custom_bg
+ .filter(|_| unplanned && webcam_planes.is_some())
+ .map(|bg| self.webcam_bg_draw(bg, g.w_dst, g.w_px, g.w_radius, 0.0, &dummy));
// L'ombre se juge sur le mode DE LA SCENE, pas sur `effect_code` : le fond
// personnalise se compose desormais en detourage (code 1) tout en gardant
// sa bulle, et tester le code compose la lui retirerait. Meme lecture que
@@ -2376,7 +2509,10 @@ impl Compositor {
scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()),
Some(e) if e.shader_code() == 1.0
) && self.webcam_mask.borrow().is_some();
- let webcam_draw = webcam_planes.as_ref().map(|(wy, wu, wv)| {
+ // Camera 0's crop, mirror and desk turn as camera settings, plus its homography
+ // (`Scene::camera(0)`): with one, `src` is not read and the crop is dropped.
+ let cam0 = g.camera0_settings(scene_ref.as_ref());
+ let webcam_draw = webcam_planes.as_ref().filter(|_| unplanned).map(|(wy, wu, wv)| {
// COVER-CROP. `src` etait cable a [0,0,1,1], donc la texture entiere
// etait etiree sur la boite quelle que soit sa forme : le facteur de
// deformation valait exactement `box_ar / cam_ar`. Invisible en PiP
@@ -2386,45 +2522,27 @@ impl Compositor {
//
// `cover_crop_uv` est la primitive partagee que macOS et Windows
// utilisent ; elle rend le rect inchange quand il a deja le bon
- // ratio, donc aucun placement correct ne bouge.
- let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect(
+ // ratio, donc aucun placement correct ne bouge. Mirror and the desk-shot
+ // turn are both bound swaps: u for horizontal, v for vertical.
+ let src = crate::frame_geometry::camera_source_rect(
+ Some(&cam0),
[wcw, wch],
[wtw as f32, wth as f32],
- scene_ref
- .as_ref()
- .and_then(|scene| scene.layout.webcam_crop),
g.w_px[0] / g.w_px[1].max(0.0001),
);
- // MIROIR : on inverse l'intervalle u. Le VS interpole `src`
- // lineairement et `fs_main` ne re-clampe pas `i.uv`, donc un
- // intervalle a l'envers suffit -- aucune retouche du WGSL. Apres le
- // cover-crop les deux bornes sont strictement a l'interieur de la
- // texture, donc le sampler ClampToEdge ne bave pas sur les bords.
- let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) };
// `src_prev` doit valoir EXACTEMENT le `src` de ce draw, miroir
// compris : le shader s'en sert pour reconstruire l'UV de la frame
// precedente, et un rect source qui ne correspond pas au calque
// dessine ferait diverger la trainee vers une zone de la texture qui
// n'a jamais ete affichee. Seul `dst_prev` porte le mouvement.
- let cb = LayerCB {
- dst: g.w_dst,
- src: [u0, cv0, u1, cv1],
- quad_px: g.w_px,
- radius_px: g.w_radius,
- mode: 0.0,
- // `color.a` porte l'alpha du decoupage (`color.a * personne`) ; le
- // RGB n'est plus lu, le fond ayant deja ete peint sous la camera.
- color: [0.0, 0.0, 0.0, 1.0],
- // `fx.xy` = etendue valide de la texture webcam, par quoi le
- // shader divise `uv` pour retomber dans l'espace du masque ;
- // `fx.z` = mode, `fx.w` = intensite du flou. Contrat commun aux
- // trois back-ends, cf. `layer.wgsl` et `webcam-segmentation.md`.
- fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
- src_prev: [u0, cv0, u1, cv1],
- dst_prev: g.w_dst_prev,
- mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
- ..Default::default()
- };
+ // `fx.xy` = etendue valide de la texture webcam, par quoi le
+ // shader divise `uv` pour retomber dans l'espace du masque ;
+ // `fx.z` = mode, `fx.w` = intensite du flou. Contrat commun aux
+ // trois back-ends, cf. `layer.wgsl` et `webcam-segmentation.md`.
+ let cb = crate::frame_geometry::with_camera_homography(
+ g.webcam_video_cb(src, w_valid, effect_code, blur_intensity),
+ Some(&cam0),
+ );
// Le masque est lie par `make_bind` sur tous les draws, pas seulement
// celui-ci : le layout l'exige (cf. `tex_entry(4)`).
self.make_bind(&cb, Some((wy, wu, wv)), &dummy)
@@ -2458,6 +2576,71 @@ impl Compositor {
self.make_bind(&cb, None, &dummy)
});
+ // Every planned camera layer, in order, each as shadow, then (camera 0) the custom
+ // background, then the video: one uniform buffer and bind group per draw, drawn in
+ // the "webcam-pass" below. Camera 0 keeps its frame, effects and mask; the extra
+ // cameras draw from `set_extra_camera_frames` without effects, and a camera without a
+ // frame is skipped. `extra_planes` keeps their views alive until the pass is encoded.
+ let mut camera_layer_draws: Vec = Vec::new();
+ let mut extra_planes = Vec::new();
+ let render = [rw, rh];
+ let cam0_base = g.webcam_video_cb([0.0; 4], w_valid, effect_code, blur_intensity);
+ let plain = |layer: LayerBind| BgDraw { layer, _tex: None, _view: None };
+ for plan in &g.camera_layers {
+ if plan.camera == 0 {
+ // `webcam_planes` is already gated on `lp.has_webcam`.
+ let Some((wy, wu, wv)) = webcam_planes.as_ref() else { continue };
+ let video = crate::frame_geometry::camera_layer_cb(
+ plan,
+ Some(&cam0),
+ [wcw, wch],
+ [wtw as f32, wth as f32],
+ render,
+ &cam0_base,
+ );
+ if let Some(shadow) =
+ g.camera_layer_shadow(plan, &video, render, cfg.shadow, is_cutout)
+ {
+ camera_layer_draws.push(plain(self.make_bind(&shadow, None, &dummy)));
+ }
+ if let Some(bg) = custom_bg {
+ let (dst, quad_px, radius_px) = (video.dst, video.quad_px, video.radius_px);
+ let t = video.layer_fx[0];
+ let bg = self.webcam_bg_draw(bg, dst, quad_px, radius_px, t, &dummy);
+ camera_layer_draws.push(bg);
+ }
+ let bind = self.make_bind(&video, Some((wy, wu, wv)), &dummy);
+ camera_layer_draws.push(plain(bind));
+ } else {
+ let frame = self.extra_camera_frame(plan.camera);
+ if Self::pixel_buffer_of(frame).is_none() {
+ continue;
+ }
+ let Ok(planes) = self.nv12_srvs(frame) else { continue };
+ let (tw, th) = self.tex_dims(frame);
+ let visible = [(*frame).width as f32, (*frame).height as f32];
+ let tex = [tw as f32, th as f32];
+ let valid = [visible[0] / tex[0].max(1.0), visible[1] / tex[1].max(1.0)];
+ let video = crate::frame_geometry::camera_layer_cb(
+ plan,
+ scene_ref.as_ref().and_then(|s| s.camera(plan.camera)),
+ visible,
+ tex,
+ render,
+ &crate::frame_geometry::extra_camera_base_cb(valid),
+ );
+ if let Some(shadow) =
+ g.camera_layer_shadow(plan, &video, render, cfg.shadow, false)
+ {
+ camera_layer_draws.push(plain(self.make_bind(&shadow, None, &dummy)));
+ }
+ let (ey, eu, ev) = &planes;
+ let bind = self.make_bind(&video, Some((ey, eu, ev)), &dummy);
+ camera_layer_draws.push(plain(bind));
+ extra_planes.push(planes);
+ }
+ }
+
// ANNOTATIONS -- calque le plus haut, place relativement au rect ecran
// (les coords x/y/w/h de l'annotation sont des fractions de ce rect, cf.
// `scene.rs`). Le rect est `g.s_ann`, l'ecran SANS ZOOM, et surtout pas
@@ -2475,16 +2658,15 @@ impl Compositor {
// replis, meme ordre. Seul le texte diverge, tinte cote shader (atlas R8)
// au lieu d'une couleur bakee dans la texture.
struct AnnDraw {
- _buf: wgpu::Buffer,
+ layer: LayerBind,
/// Gardent l'atlas / la texture image en vie jusqu'au submit. `None`
/// pour les quads qui n'echantillonnent rien (plaque de fond, fleche).
_glyphs: Option,
_tex: Option,
- bind: wgpu::BindGroup,
}
impl AnnDraw {
- fn plain(buf: wgpu::Buffer, bind: wgpu::BindGroup) -> AnnDraw {
- AnnDraw { _buf: buf, _glyphs: None, _tex: None, bind }
+ fn plain(layer: LayerBind) -> AnnDraw {
+ AnnDraw { layer, _glyphs: None, _tex: None }
}
}
// FENETRE TEMPORELLE. Sans ce test, TOUTES les annotations du projet sont
@@ -2502,6 +2684,8 @@ impl Compositor {
.as_ref()
.is_some_and(|s| s.annotations.iter().any(|a| a.kind == "blur" && visible(a)));
let mut ann_draws: Vec = Vec::new();
+ // Les flous de confidentialite, dessines sur le metrage avant le curseur.
+ let mut privacy_draws: Vec = Vec::new();
if let Some(scene) = scene_ref.as_ref() {
// La liste arrive deja triee par zIndex cote app : l'ordre d'iteration
// EST l'ordre de peinture.
@@ -2543,8 +2727,7 @@ impl Compositor {
mb: [1.0, half_stroke, 0.0, 0.0],
..Default::default()
};
- let (buf, bind) = self.make_bind(&cb, None, &dummy);
- ann_draws.push(AnnDraw::plain(buf, bind));
+ ann_draws.push(AnnDraw::plain(self.make_bind(&cb, None, &dummy)));
}
"blur" => {
let Some(blur) = a.blur.as_ref() else { continue };
@@ -2587,12 +2770,12 @@ impl Compositor {
};
// La copie mipmappee au binding 1 (texY), la ou le mode 10
// la lit.
- let (buf, bind) = self.make_bind(
+ let layer = self.make_bind(
&cb,
Some((&self.ann_copy_view, &self.ann_copy_view, &self.ann_copy_view)),
&dummy,
);
- ann_draws.push(AnnDraw::plain(buf, bind));
+ privacy_draws.push(AnnDraw::plain(layer));
}
"image" => {
let Some(src) = a.image_path.as_ref().filter(|s| !s.is_empty()) else {
@@ -2649,13 +2832,8 @@ impl Compositor {
fx: [0.0, 0.0, 1.0, 1.0],
..Default::default()
};
- let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy);
- ann_draws.push(AnnDraw {
- _buf: buf,
- _glyphs: None,
- _tex: Some(tex),
- bind,
- });
+ let layer = self.make_bind(&cb, Some((&view, &view, &view)), &dummy);
+ ann_draws.push(AnnDraw { layer, _glyphs: None, _tex: Some(tex) });
}
"text" => {
let Some(raster) = self.text_raster.as_ref() else { continue };
@@ -2663,6 +2841,13 @@ impl Compositor {
if text.content.trim().is_empty() {
continue;
}
+ // The desk label shows only while the camera is covered: skip the rest of
+ // its section before rasterizing anything.
+ if crate::text_anim::is_desk_cover(text.animation.as_deref())
+ && g.webcam_cover <= 0.0
+ {
+ continue;
+ }
let color = parse_hex(&text.color).unwrap_or([1.0, 1.0, 1.0, 1.0]);
let background =
parse_hex(&text.background_color).unwrap_or([0.0, 0.0, 0.0, 0.0]);
@@ -2697,10 +2882,11 @@ impl Compositor {
// et remis a l'echelle de la sortie, comme la taille de
// police : en px absolus la meme animation sauterait deux
// fois plus haut dans un rendu 4K que dans l'apercu.
- let anim = crate::text_anim::text_animation_state(
+ let anim = crate::text_anim::annotation_text_state(
text.animation.as_deref(),
(g.source_t - a.start_sec as f32) * 1000.0,
((a.end_sec - a.start_sec) * 1000.0) as f32,
+ g.webcam_cover,
);
let anim_px = rh / crate::text_anim::ANIMATION_REFERENCE_HEIGHT;
let (mut ax, mut ay, mut aw, mut ah) = (
@@ -2784,8 +2970,7 @@ impl Compositor {
),
..Default::default()
};
- let (pbuf, pbind) = self.make_bind(&plate, None, &dummy);
- ann_draws.push(AnnDraw::plain(pbuf, pbind));
+ ann_draws.push(AnnDraw::plain(self.make_bind(&plate, None, &dummy)));
}
}
@@ -2798,14 +2983,9 @@ impl Compositor {
..Default::default()
};
// Atlas R8 au binding 1 (texY) que le mode 11 echantillonne.
- let (buf, bind) =
+ let layer =
self.make_bind(&cb, Some((&glyphs.view, &glyphs.view, &glyphs.view)), &dummy);
- ann_draws.push(AnnDraw {
- _buf: buf,
- _glyphs: Some(glyphs),
- _tex: None,
- bind,
- });
+ ann_draws.push(AnnDraw { layer, _glyphs: Some(glyphs), _tex: None });
}
_ => {}
}
@@ -2822,12 +3002,18 @@ impl Compositor {
// sont PAS dessinees sur le RT mais dans `accum`, puis compositees en une
// fois -- cf. le commentaire au point de dessin.
struct CursorDraw {
- _bufs: Vec,
/// Le sprite, et pour le curseur modelise son champ de distance.
_tex: Vec<(wgpu::Texture, wgpu::TextureView)>,
- binds: Vec,
+ /// Une copie par echantillon de la trainee, le sprite (modes 7 et 13) ou le curseur
+ /// modelise (mode 15).
+ binds: Vec,
+ /// L'union des quads de `binds` (x0, y0, x1, y1 en fractions de la sortie) : tout ce
+ /// que la trainee peint dans `accum`, donc tout ce que sa recopie doit relire.
+ bounds: [f32; 4],
/// L'impact des clics (mode 16), dessine SOUS le curseur, sur le RT.
- impacts: Vec,
+ impacts: Vec,
+ /// Le cristal de Prism Glow : il lit la copie de l'image composee (`CursorPlan::glass`).
+ glass: bool,
}
let cursor_draw: Option = (|| {
let track = cursor_ref.as_ref()?;
@@ -2870,13 +3056,18 @@ impl Compositor {
})
.collect()
};
- let (mut bufs, mut binds) = (Vec::new(), Vec::new());
- let mut impacts = Vec::new();
- for cb in &plan.impacts {
- let (buf, bind) = self.make_bind(cb, None, &dummy);
- bufs.push(buf);
- impacts.push(bind);
- }
+ let mut binds = Vec::new();
+ let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
+ let mut grow = |dst: [f32; 4]| {
+ bounds = [
+ bounds[0].min(dst[0]),
+ bounds[1].min(dst[1]),
+ bounds[2].max(dst[0] + dst[2]),
+ bounds[3].max(dst[1] + dst[3]),
+ ];
+ };
+ let impacts: Vec =
+ plan.impacts.iter().map(|cb| self.make_bind(cb, None, &dummy)).collect();
let sprites = scene_ref
.as_ref()
@@ -2906,7 +3097,9 @@ impl Compositor {
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
// Curseur modelise (mode 15) : ce meme sprite extrude, `plan_cursor` en a tire la
- // pose. Sprite au binding 1 (texY), champ au binding 2 (texU). Parite Windows/macOS.
+ // pose. La copie de l'image composee au binding 1 (texY), que refracte le cristal de
+ // Prism Glow, le champ au binding 4 (texMask), le sprite au binding 6 (texDof).
+ // Parite Windows/macOS.
if let Some(pose) = plan.model {
match self.cursor_sdf(&sprite.path) {
Ok((sdf, shape)) => {
@@ -2923,16 +3116,21 @@ impl Compositor {
) else {
continue;
};
- let (buf, bind) =
- self.make_bind(&cb, Some((&view, &sdf_view, &view)), &dummy);
- bufs.push(buf);
- binds.push(bind);
+ binds.push(self.make_bind_b4(
+ &cb,
+ Some((&self.ann_copy_view, &dummy, &dummy)),
+ &dummy,
+ Some(&sdf_view),
+ Some(&view),
+ ));
+ grow(cb.dst);
}
return (!binds.is_empty()).then_some(CursorDraw {
- _bufs: bufs,
_tex: vec![(tex, view), (sdf, sdf_view)],
binds,
+ bounds,
impacts,
+ glass: plan.glass,
});
}
Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path),
@@ -2952,11 +3150,16 @@ impl Compositor {
[rw, rh],
);
// Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne.
- let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy);
- bufs.push(buf);
- binds.push(bind);
+ binds.push(self.make_bind(&cb, Some((&view, &view, &view)), &dummy));
+ grow(cb.dst);
}
- Some(CursorDraw { _bufs: bufs, _tex: vec![(tex, view)], binds, impacts })
+ Some(CursorDraw {
+ _tex: vec![(tex, view)],
+ binds,
+ bounds,
+ impacts,
+ glass: false,
+ })
})();
// Bind group de la passe de composition d'`accum` (layout du blur :
// uniform + texture + sampler). Construit hors de la pass, comme les
@@ -2998,7 +3201,7 @@ impl Compositor {
});
// Passe 0 : pyramide de profondeur de champ, niveau 0 vide d'abord (le « over »
// rend alors la source telle quelle), puis ses mips.
- if let (Some(p), Some((_buf, bind))) = (dof_pyramid, &dof_fill) {
+ if let (Some(p), Some(fill)) = (dof_pyramid, &dof_fill) {
{
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("dof-pyramid-pass"),
@@ -3015,8 +3218,7 @@ impl Compositor {
occlusion_query_set: None,
});
rpass.set_pipeline(&self.pipeline);
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
+ self.draw_layer(&mut rpass, fill, false);
}
self.generate_mips(&mut encoder, &p.mips);
}
@@ -3039,38 +3241,74 @@ impl Compositor {
rpass.set_pipeline(&self.pipeline);
self.draw_screen_group(&mut rpass, &screen_shadow, &window_frame, &screen_bind, &device_frame);
}
- // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel).
- {
- let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
- label: Some("bg-pass"),
- color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- view: &self.rt_view,
- resolve_target: None,
- ops: wgpu::Operations {
- load: wgpu::LoadOp::Clear(wgpu::Color {
- r: bg_clear[0] as f64,
- g: bg_clear[1] as f64,
- b: bg_clear[2] as f64,
- a: bg_clear[3] as f64,
- }),
- store: wgpu::StoreOp::Store,
- },
- })],
- depth_stencil_attachment: None,
- timestamp_writes: None,
- occlusion_query_set: None,
- });
- if let Some(bg) = &bg_draw {
- rpass.set_pipeline(&self.pipeline);
- rpass.set_bind_group(0, &bg.bind, &[]);
- rpass.draw(0..4, 0..1);
+ // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel) puis son flou -- ou, s'il
+ // n'a pas change (`BackgroundKey`), la copie qu'en a gardee la frame d'avant.
+ let extent = wgpu::Extent3d {
+ width: self.render_w,
+ height: self.render_h,
+ depth_or_array_layers: 1,
+ };
+ if bg_cached {
+ let cache = self.bg_cache.borrow();
+ let tex = &cache.as_ref().expect("bg_cached").tex;
+ encoder.copy_texture_to_texture(tex.as_image_copy(), self.rt.as_image_copy(), extent);
+ } else {
+ {
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("bg-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &self.rt_view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color {
+ r: bg_clear[0] as f64,
+ g: bg_clear[1] as f64,
+ b: bg_clear[2] as f64,
+ a: bg_clear[3] as f64,
+ }),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ if let Some(bg) = &bg_draw {
+ rpass.set_pipeline(&self.pipeline);
+ self.draw_layer(&mut rpass, &bg.layer, false);
+ }
+ }
+ // Blur du fond (avant l'ecran), si active par la scene/l'inspector.
+ self.blur_bg(&mut encoder, cfg.bg_blur);
+ // Pas de cache pour un fond anime, ni pour un fond qui n'a pas pu se charger : la frame
+ // suivante reessaie.
+ let key = (bg_draw.is_some() == bg_wanted)
+ .then(|| {
+ crate::frame_geometry::BackgroundKey::of(bg_desc, cfg.bg_blur, lp.bg_color, [rw, rh])
+ })
+ .flatten();
+ if let Some(key) = key {
+ let mut cache = self.bg_cache.borrow_mut();
+ let tex = match cache.take() {
+ Some(c) if c.tex.size() == extent => c.tex,
+ _ => self.gpu.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("bg-cache"),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ }),
+ };
+ encoder.copy_texture_to_texture(self.rt.as_image_copy(), tex.as_image_copy(), extent);
+ *cache = Some(BgCache { key, tex });
}
}
- // Blur du fond (avant l'ecran), si active par la scene/l'inspector.
- self.blur_bg(&mut encoder, cfg.bg_blur);
- // Passe 2 : avant-plan (ecran + webcam), compose par-dessus le fond
- // (eventuellement floute) avec `LoadOp::Load`. Les annotations sont dans
- // une passe a part, cf. plus bas.
+ // Passe 2 : l'ecran, compose par-dessus le fond (eventuellement floute) avec
+ // `LoadOp::Load`. Puis les flous de confidentialite, le curseur, la camera et les autres
+ // annotations, dans cet ordre : celui de Windows et macOS.
{
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("fg-pass"),
@@ -3091,10 +3329,7 @@ impl Compositor {
// elle doit passer sous lui mais au-dessus du fond (et, pour la
// camera, au-dessus de l'ecran).
match &trail {
- Some((_buf, bind)) => {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
- }
+ Some(layer) => self.draw_layer(&mut rpass, layer, false),
None => self.draw_screen_group(
&mut rpass,
&screen_shadow,
@@ -3103,40 +3338,22 @@ impl Compositor {
&device_frame,
),
}
- if let Some((_buf, bind)) = &webcam_shadow {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
- }
- // Fond personnalise : ENTRE l'ombre et la camera. C'est ce sandwich qui
- // remplace la branche « mode 3 » du shader — la camera, decoupee, se
- // fond dessus par alpha ; l'ombre reste dessous, elle appartient a la
- // bulle et non a son contenu.
- if let Some(bg) = &webcam_bg {
- rpass.set_bind_group(0, &bg.bind, &[]);
- rpass.draw(0..4, 0..1);
- }
- if let Some((_buf, bind)) = &webcam_draw {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
- }
}
// Fige la frame composee pour les annotations « flou ». ICI et nulle part
- // ailleurs : apres l'ecran et la camera (sinon un flou masquerait du vide)
- // et avant la premiere annotation (sinon deux flous qui se recouvrent
+ // ailleurs : apres l'ecran (sinon un flou masquerait du vide)
+ // et avant le premier flou (sinon deux flous qui se recouvrent
// s'echantillonnent l'un l'autre). Une passe de rendu ne peut pas lire sa
// propre cible, d'ou la copie -- et d'ou le fait que les annotations
// doivent avoir leur propre passe.
if needs_ann_copy {
self.generate_ann_mips(&mut encoder);
}
- // Passe 3 : annotations puis curseur net, par-dessus tout le reste. Elle
- // existe meme sans flou : deux passes consecutives sur la MEME cible avec
- // `LoadOp::Load` ne coutent rien de plus qu'une seule sur un GPU
- // desktop, et un seul chemin de code vaut mieux qu'un branchement qui ne
- // serait exerce que dans un projet sur dix.
- {
+ // Passe 2 bis : les flous de confidentialite, sur le metrage, AVANT le curseur (parite
+ // Windows et macOS). Le curseur reste net par-dessus, et le cristal de Prism Glow, qui
+ // refracte l'image composee, n'y voit que des pixels deja floutes.
+ if !privacy_draws.is_empty() {
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
- label: Some("ann-pass"),
+ label: Some("privacy-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.rt_view,
resolve_target: None,
@@ -3150,21 +3367,44 @@ impl Compositor {
occlusion_query_set: None,
});
rpass.set_pipeline(&self.pipeline);
- for a in &ann_draws {
- rpass.set_bind_group(0, &a.bind, &[]);
- rpass.draw(0..4, 0..1);
+ for a in &privacy_draws {
+ self.draw_layer(&mut rpass, &a.layer, false);
}
+ }
+ // Le cristal de Prism Glow refracte l'image telle qu'elle est composee a cet instant,
+ // flous compris : sa copie dans `ann_copy` (libre, les flous l'ont deja lue ; le mip 0
+ // suffit), une fois pour toutes les copies de la trainee.
+ if cursor_draw.as_ref().is_some_and(|c| c.glass) {
+ encoder.copy_texture_to_texture(
+ self.rt.as_image_copy(),
+ self.ann_copy.as_image_copy(),
+ wgpu::Extent3d { width: self.render_w, height: self.render_h, depth_or_array_layers: 1 },
+ );
+ }
+ // Le curseur net, et l'impact des clics sous lui. La trainee, elle, a besoin de sa
+ // propre cible : elle suit.
+ {
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("cursor-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &self.rt_view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ rpass.set_pipeline(&self.pipeline);
// L'impact des clics, sous le curseur et sa trainee (dessinee plus bas).
- for bind in cursor_draw.iter().flat_map(|c| &c.impacts) {
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
+ for layer in cursor_draw.iter().flat_map(|c| &c.impacts) {
+ self.draw_layer(&mut rpass, layer, false);
}
- // Curseur en dernier : au-dessus de l'ecran et des annotations.
- // Une seule copie = curseur net, il tient dans cette pass. La
- // trainee, elle, a besoin de sa propre cible (voir plus bas).
if let Some(c) = cursor_draw.as_ref().filter(|c| c.binds.len() == 1) {
- rpass.set_bind_group(0, &c.binds[0], &[]);
- rpass.draw(0..4, 0..1);
+ self.draw_layer(&mut rpass, &c.binds[0], false);
}
}
// TRAINEE DU CURSEUR : flou REEL, pas des copies discretes.
@@ -3200,11 +3440,10 @@ impl Compositor {
occlusion_query_set: None,
});
rpass.set_pipeline(&self.pipeline_add);
- for (k, bind) in c.binds.iter().enumerate() {
+ for (k, layer) in c.binds.iter().enumerate() {
let w = crate::frame_geometry::cursor_tap_weight(k as u32, c.binds.len() as u32) as f64;
rpass.set_blend_constant(wgpu::Color { r: w, g: w, b: w, a: w });
- rpass.set_bind_group(0, bind, &[]);
- rpass.draw(0..4, 0..1);
+ self.draw_layer(&mut rpass, layer, true);
}
}
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@@ -3221,9 +3460,79 @@ impl Compositor {
timestamp_writes: None,
occlusion_query_set: None,
});
- rpass.set_pipeline(&self.pipeline_copy);
- rpass.set_bind_group(0, abind, &[]);
- rpass.draw(0..3, 0..1);
+ // Rien n'a ete peint dans `accum` hors des quads de la trainee : la recopie s'y borne
+ // au lieu de repasser sur toute la sortie, ce qui coutait 0,5 ms par frame 1080p
+ // (Radeon 610M) pour un curseur de quelques dizaines de pixels.
+ let (w, h) = (self.render_w as f32, self.render_h as f32);
+ let x0 = (c.bounds[0] * w).floor().clamp(0.0, w) as u32;
+ let y0 = (c.bounds[1] * h).floor().clamp(0.0, h) as u32;
+ let x1 = (c.bounds[2] * w).ceil().clamp(0.0, w) as u32;
+ let y1 = (c.bounds[3] * h).ceil().clamp(0.0, h) as u32;
+ if x1 > x0 && y1 > y0 {
+ rpass.set_scissor_rect(x0, y0, x1 - x0, y1 - y0);
+ rpass.set_pipeline(&self.pipeline_copy);
+ rpass.set_bind_group(0, abind, &[]);
+ rpass.draw(0..3, 0..1);
+ }
+ }
+ // La camera, au-dessus de l'ecran et du curseur.
+ {
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("webcam-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &self.rt_view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ rpass.set_pipeline(&self.pipeline);
+ if let Some(layer) = &webcam_shadow {
+ self.draw_layer(&mut rpass, layer, false);
+ }
+ // Fond personnalise : ENTRE l'ombre et la camera. C'est ce sandwich qui
+ // remplace la branche « mode 3 » du shader — la camera, decoupee, se
+ // fond dessus par alpha ; l'ombre reste dessous, elle appartient a la
+ // bulle et non a son contenu.
+ if let Some(bg) = &webcam_bg {
+ self.draw_layer(&mut rpass, &bg.layer, false);
+ }
+ if let Some(layer) = &webcam_draw {
+ self.draw_layer(&mut rpass, layer, false);
+ }
+ for draw in &camera_layer_draws {
+ self.draw_layer(&mut rpass, &draw.layer, false);
+ }
+ }
+ // Passe 3 : les autres annotations, par-dessus tout le reste (les flous sont passes avant
+ // le curseur). Elle existe meme sans annotation : deux passes consecutives sur la MEME
+ // cible avec `LoadOp::Load` ne coutent rien de plus qu'une seule sur un GPU desktop, et un
+ // seul chemin de code vaut mieux qu'un branchement qui ne serait exerce que dans un projet
+ // sur dix.
+ {
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("ann-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &self.rt_view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ rpass.set_pipeline(&self.pipeline);
+ for a in &ann_draws {
+ self.draw_layer(&mut rpass, &a.layer, false);
+ }
}
self.gpu.context.submit(std::iter::once(encoder.finish()));
Ok(())
@@ -4041,7 +4350,7 @@ mod tests {
planes: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
) -> (u32, u32, Vec) {
let dummy = comp.dummy_view();
- let (_buf, bind) = comp.make_bind(cb, Some(planes), &dummy);
+ let layer = comp.make_bind(cb, Some(planes), &dummy);
let mut encoder = comp.gpu.device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("test-layer") },
);
@@ -4061,8 +4370,7 @@ mod tests {
occlusion_query_set: None,
});
rpass.set_pipeline(&comp.pipeline);
- rpass.set_bind_group(0, &bind, &[]);
- rpass.draw(0..4, 0..1);
+ comp.draw_layer(&mut rpass, &layer, false);
}
comp.gpu.context.submit(std::iter::once(encoder.finish()));
unsafe { comp.readback_direct().expect("readback_direct") }
@@ -4559,12 +4867,37 @@ mod tests {
/// `compose_pip` sur une scene deja construite, pour les tests qui la retouchent.
fn compose_pip_scene(comp: &Compositor, gpu: &Gpu, scene: Scene, shadow: bool) -> Vec {
+ let screen = FakeFrame::new(gpu, 128, 128, |_, _| 126);
+ let webcam = FakeFrame::new(gpu, 64, 64, |_, _| Y_WHITE);
+ compose_pip_frames(comp, scene, shadow, &screen, &webcam)
+ }
+
+ /// `compose_pip_scene` with the caller's own screen and camera frames.
+ fn compose_pip_frames(
+ comp: &Compositor,
+ scene: Scene,
+ shadow: bool,
+ screen: &FakeFrame,
+ webcam: &FakeFrame,
+ ) -> Vec {
+ compose_pip_frames_with_extras(comp, scene, shadow, screen, webcam, &[])
+ }
+
+ /// `compose_pip_frames` with extra camera frames. They are handed over AFTER `set_scene`,
+ /// which forgets the previous ones, and before the compose, as the product code does.
+ fn compose_pip_frames_with_extras(
+ comp: &Compositor,
+ scene: Scene,
+ shadow: bool,
+ screen: &FakeFrame,
+ webcam: &FakeFrame,
+ extra: &[*const AVFrame],
+ ) -> Vec {
comp.set_live_params(live_params_from_scene(&scene));
comp.set_has_webcam(true);
comp.set_scene(Some(scene));
+ unsafe { comp.set_extra_camera_frames(extra) };
- let screen = FakeFrame::new(gpu, 128, 128, |_, _| 126);
- let webcam = FakeFrame::new(gpu, 64, 64, |_, _| Y_WHITE);
let mut cfg = Cfg::c8();
cfg.bg_blur = 0.0;
cfg.zoom = false;
@@ -4679,6 +5012,9 @@ mod tests {
clip_index: None,
start_sec: 2.0,
end_sec: 8.0,
+ rotation: 0,
+ mirror: None,
+ full_frame: false,
});
// 5 s : la montee (~1 s) est finie, le retour (~1,5 s) n'a pas commence.
comp.set_timeline_time(Some(5.0));
@@ -4690,6 +5026,241 @@ mod tests {
);
}
+ /// `pip_scene_json` on a black background: with a black screen too, the camera is drawn over
+ /// black wherever it lands, so its colour can be read without knowing where the PiP is.
+ fn black_pip_scene() -> Scene {
+ let json = pip_scene_json(NO_EFFECT).replace("#0080ff", "#000000");
+ Scene::from_json(&json).expect("scene json")
+ }
+
+ /// The x of every pixel of a `width`-wide RGBA image that `pred` accepts.
+ fn xs_where(rgba: &[u8], width: usize, pred: impl Fn(&[u8]) -> bool) -> Vec {
+ rgba.chunks_exact(4)
+ .enumerate()
+ .filter(|(_, px)| pred(px))
+ .map(|(i, _)| i % width)
+ .collect()
+ }
+
+ /// The RGBA of pixel (x, y) of a 320-wide readback.
+ fn pixel_at(rgba: &[u8], x: usize, y: usize) -> [u8; 4] {
+ let i = (y * 320 + x) * 4;
+ [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
+ }
+
+ /// A 64x64 camera frame of one RGB colour (BT.709 limited, `rgb_to_nv12`).
+ fn solid_camera(gpu: &Gpu, rgb: [u8; 3]) -> FakeFrame {
+ let (w, h) = (64u32, 64u32);
+ let pixels: Vec = (0..w * h).flat_map(|_| rgb).collect();
+ let (y, uv) = rgb_to_nv12(&pixels, w, h);
+ FakeFrame::from_planes(gpu, w, h, &y, &uv)
+ }
+
+ /// Camera 1 over the whole frame, and camera 0 as a PiP in the bottom-right corner
+ /// (x 224..304, y 126..171 of a 320x180 frame).
+ const CAMERA_1_FULL: &str = r#"{"camera":1,"rect":{"x":0,"y":0,"width":1,"height":1},"shape":"rectangle","fillsFrame":true}"#;
+ const CAMERA_0_PIP: &str = r#"{"camera":0,"rect":{"x":0.7,"y":0.7,"width":0.25,"height":0.25},"shape":"rectangle"}"#;
+
+ /// A camera layout region from 2 s to 8 s with these layers (JSON objects, comma-separated).
+ fn layout_region(layers: &str) -> crate::scene::SceneCameraLayoutRegion {
+ serde_json::from_str(&format!(r#"{{"startSec":2.0,"endSec":8.0,"layers":[{layers}]}}"#))
+ .expect("region json")
+ }
+
+ /// The time in the lead-in of `layout_region` where a layer only the region shows is drawn
+ /// at opacity 0.5 (and one only the default shows, at 0.5 too). Checked against the plan.
+ fn half_way_in(region: &crate::scene::SceneCameraLayoutRegion) -> f32 {
+ let (mut lo, mut hi) = (0.0f32, 1.0f32);
+ for _ in 0..40 {
+ let mid = 0.5 * (lo + hi);
+ if crate::regions::ease_out_screen_studio(mid) < 0.5 {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ let t = 2.0 + lo * crate::regions::TRANSITION_WINDOW_S;
+ let default_cam0 = crate::camera_layers::CameraLayerPlan {
+ camera: 0,
+ dst: [0.7, 0.7, 0.25, 0.25],
+ radius_frac: 0.0,
+ shape: 0,
+ opacity: 1.0,
+ fills_frame: false,
+ };
+ let clock = crate::regions::ScreenClock::new(&[], 0);
+ let layers = crate::camera_layers::camera_layers_at(
+ std::slice::from_ref(region),
+ &[],
+ t,
+ &clock,
+ Some(default_cam0),
+ );
+ assert!(
+ layers.iter().any(|l| (l.opacity - 0.5).abs() < 1e-3),
+ "no layer at half opacity at {t}: {layers:?}"
+ );
+ t
+ }
+
+ /// `compose_pip_frames` at source time `t`, with these extra camera frames set (and cleared
+ /// again afterwards).
+ fn compose_layers(
+ comp: &Compositor,
+ scene: Scene,
+ t: f32,
+ screen: &FakeFrame,
+ camera_0: &FakeFrame,
+ extra: &[*const AVFrame],
+ ) -> Vec {
+ comp.set_timeline_time(Some(t));
+ let rgba = compose_pip_frames_with_extras(comp, scene, false, screen, camera_0, extra);
+ unsafe { comp.set_extra_camera_frames(&[]) };
+ rgba
+ }
+
+ fn is_green(px: [u8; 4]) -> bool {
+ px[1] > 200 && px[0] < 60 && px[2] < 60
+ }
+
+ /// A `camera-full-pip` region: camera 1 (green) fills the frame, camera 0 (white) is the PiP
+ /// over it. The centre shows camera 1, the PiP rect camera 0.
+ #[test]
+ fn two_cameras_draw_in_their_planned_rects() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let screen = FakeFrame::new(&gpu, 128, 128, |_, _| Y_BLACK);
+ let white = FakeFrame::new(&gpu, 64, 64, |_, _| Y_WHITE);
+ let green = solid_camera(&gpu, [0, 255, 0]);
+ let mut scene = black_pip_scene();
+ scene.camera_layout_regions.push(layout_region(&format!("{CAMERA_1_FULL},{CAMERA_0_PIP}")));
+ // 5 s: past the lead-in, before the lead-out.
+ let rgba = compose_layers(&comp, scene, 5.0, &screen, &white, &[green.as_ptr()]);
+ let centre = pixel_at(&rgba, 160, 90);
+ assert!(is_green(centre), "the centre is not camera 1: {centre:?}");
+ let pip = pixel_at(&rgba, 264, 148);
+ assert!(pip[..3].iter().all(|&c| c > 240), "the PiP rect is not camera 0: {pip:?}");
+ let corner = pixel_at(&rgba, 10, 10);
+ assert!(is_green(corner), "camera 1 does not fill the frame: {corner:?}");
+ }
+
+ /// Without a frame for camera 1 its layer is skipped, empty slice or null pointer alike:
+ /// the centre shows the screen, as it does without any region.
+ #[test]
+ fn a_missing_extra_camera_skips_its_layer() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let screen = FakeFrame::new(&gpu, 128, 128, |_, _| 126);
+ let white = FakeFrame::new(&gpu, 64, 64, |_, _| Y_WHITE);
+ let control = compose_layers(&comp, black_pip_scene(), 5.0, &screen, &white, &[]);
+ let screen_px = pixel_at(&control, 160, 90);
+ assert!(screen_px[1] > 100 && screen_px[1] < 160, "control: the centre is not the screen");
+
+ let mut scene = black_pip_scene();
+ scene.camera_layout_regions.push(layout_region(&format!("{CAMERA_1_FULL},{CAMERA_0_PIP}")));
+ for extra in [&[][..], &[std::ptr::null()][..]] {
+ let rgba = compose_layers(&comp, scene.clone(), 5.0, &screen, &white, extra);
+ assert_eq!(pixel_at(&rgba, 160, 90), screen_px, "{} frames set", extra.len());
+ let pip = pixel_at(&rgba, 264, 148);
+ assert!(pip[..3].iter().all(|&c| c > 240), "camera 0 is no longer drawn: {pip:?}");
+ }
+ }
+
+ /// Half-way into the lead-in camera 1 fades in at opacity 0.5: over the black screen the
+ /// centre is half as green as in the hold.
+ #[test]
+ fn a_fading_extra_camera_is_half_transparent() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let screen = FakeFrame::new(&gpu, 128, 128, |_, _| Y_BLACK);
+ let white = FakeFrame::new(&gpu, 64, 64, |_, _| Y_WHITE);
+ let green = solid_camera(&gpu, [0, 255, 0]);
+ let region = layout_region(&format!("{CAMERA_1_FULL},{CAMERA_0_PIP}"));
+ let t = half_way_in(®ion);
+ let mut scene = black_pip_scene();
+ scene.camera_layout_regions.push(region);
+
+ let hold = compose_layers(&comp, scene.clone(), 5.0, &screen, &white, &[green.as_ptr()]);
+ let full = pixel_at(&hold, 160, 90);
+ assert!(is_green(full), "control: the centre is not camera 1 in the hold: {full:?}");
+ let half = compose_layers(&comp, scene, t, &screen, &white, &[green.as_ptr()]);
+ let px = pixel_at(&half, 160, 90);
+ let expected = full[1] as i32 / 2;
+ assert!(
+ (px[1] as i32 - expected).abs() <= 8 && px[0] < 30 && px[2] < 30,
+ "half-way in: {px:?}, expected green near {expected}"
+ );
+ }
+
+ /// Camera 0 fading out at opacity 0.5 (a region that shows only camera 1, which has no
+ /// frame) draws the (white) camera at half strength over the (black) screen: every camera
+ /// pixel is mid grey, none of them is white any more.
+ #[test]
+ fn transparency_half_halves_the_camera_over_the_screen() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let screen = FakeFrame::new(&gpu, 128, 128, |_, _| Y_BLACK);
+ let webcam = FakeFrame::new(&gpu, 64, 64, |_, _| Y_WHITE);
+ let opaque = compose_pip_frames(&comp, black_pip_scene(), false, &screen, &webcam);
+ let whole = camera_pixels(&opaque);
+ assert!(whole > 200, "the camera is not on screen, the test proves nothing");
+
+ let region = layout_region(CAMERA_1_FULL);
+ let t = half_way_in(®ion);
+ let mut scene = black_pip_scene();
+ scene.camera_layout_regions.push(region);
+ let half = compose_layers(&comp, scene, t, &screen, &webcam, &[]);
+ assert_eq!(camera_pixels(&half), 0, "a half transparent camera still has white pixels");
+ let grey = half
+ .chunks_exact(4)
+ .filter(|px| px[..3].iter().all(|&c| (126..=130).contains(&c)))
+ .count();
+ assert!(
+ grey as f32 >= whole as f32 * 0.9,
+ "{grey} mid-grey pixels for {whole} camera pixels drawn opaque"
+ );
+ }
+
+ /// Camera 0's perspective applies without any layout region. A homography that flips the
+ /// camera horizontally draws its right half on the left of the layer: a camera red on the
+ /// left and blue on the right shows blue left of red. The control (no perspective) shows it
+ /// the other way round, so the flip is the homography's doing.
+ #[test]
+ fn camera_0_perspective_applies_without_layout_regions() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized");
+ let screen = FakeFrame::new(&gpu, 128, 128, |_, _| Y_BLACK);
+ // BT.709 limited: red = (Y 63, Cb 102, Cr 240), blue = (Y 32, Cb 240, Cr 118).
+ let (w, h) = (64u32, 64u32);
+ let y: Vec = (0..w * h).map(|i| if i % w < w / 2 { 63 } else { 32 }).collect();
+ let uv: Vec = (0..h / 2)
+ .flat_map(|_| (0..w / 2).flat_map(|j| if j < w / 4 { [102, 240] } else { [240, 118] }))
+ .collect();
+ let webcam = FakeFrame::from_planes(&gpu, w, h, &y, &uv);
+ let red = |px: &[u8]| px[0] > 200 && px[1] < 60 && px[2] < 60;
+ let blue = |px: &[u8]| px[2] > 200 && px[0] < 60 && px[1] < 60;
+
+ let plain = compose_pip_frames(&comp, black_pip_scene(), false, &screen, &webcam);
+ let (r, b) = (xs_where(&plain, 320, red), xs_where(&plain, 320, blue));
+ assert!(r.len() > 50 && b.len() > 50, "control: {} red, {} blue pixels", r.len(), b.len());
+ assert!(r.iter().max() < b.iter().min(), "control: the camera's red half is on the left");
+
+ let mut scene = black_pip_scene();
+ assert!(scene.camera_layout_regions.is_empty());
+ scene.cameras.push(crate::scene::SceneCamera {
+ index: 0,
+ rotation: 0,
+ mirror: None,
+ crop: None,
+ homography: Some([-1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
+ aspect: None,
+ });
+ let flipped = compose_pip_frames(&comp, scene, false, &screen, &webcam);
+ let (r, b) = (xs_where(&flipped, 320, red), xs_where(&flipped, 320, blue));
+ assert!(r.len() > 50 && b.len() > 50, "flipped: {} red, {} blue pixels", r.len(), b.len());
+ assert!(b.iter().max() < r.iter().min(), "flipped: the left half of the layer is blue");
+ }
+
/// Le tour complet, celui qui a besoin d'ONNX Runtime : capture -> inference
/// -> masque -> composite, entraine par `compose_frame` seul. Se saute
/// proprement sans la bibliotheque, ce que fait la CI — cf.
@@ -4915,12 +5486,23 @@ mod tests {
/// Ecran gris sur fond gris moyen, avec ou sans cadre de fenetre (`frame` est insere tel
/// quel dans `effects`), droit ou incline (`rotation`, JSON).
fn compose_framed(comp: &Compositor, gpu: &Gpu, frame: &str, rotation: &str) -> Vec {
+ compose_framed_shadow(comp, gpu, frame, rotation, 0.6)
+ }
+
+ /// `compose_framed`, avec l'ombre de l'ecran `shadow` (0 l'eteint).
+ fn compose_framed_shadow(
+ comp: &Compositor,
+ gpu: &Gpu,
+ frame: &str,
+ rotation: &str,
+ shadow: f32,
+ ) -> Vec {
let json = format!(
r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}],
"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded",
"webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false,
"screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}},
- "effects":{{"padding":0.2,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":0{frame}}},
+ "effects":{{"padding":0.2,"blur":false,"shadow":{shadow},"roundnessFrac":0.03,"motionBlur":0{frame}}},
"background":{{"kind":"color","color":"#6070a0"}},
"zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}],
"annotations":[],
@@ -5203,6 +5785,66 @@ mod tests {
assert!(right[2] > 200 && right[0] < 60 && right[1] < 60, "droite {right:?} : bleu attendu");
}
+ /// Le fond fixe est recopie d'une frame a l'autre (`bg_cache`) au lieu d'etre redessine, et
+ /// un fond qui change ne laisse rien du precedent : un compositeur qui a rendu A, A, B puis A
+ /// rend chacun a l'octet comme un compositeur neuf. Un fond anime n'est jamais repris.
+ #[test]
+ fn a_static_background_is_reused_and_a_changed_one_redrawn() {
+ let Some(gpu) = gpu() else { return };
+ let screen = FakeFrame::new(&gpu, 640, 360, |_, _| 60);
+ let webcam = FakeFrame::new(&gpu, 64, 64, |_, _| 60);
+ let render = |comp: &Compositor, background: &str| {
+ let json = format!(
+ r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}],
+ "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded",
+ "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false,
+ "screenRect":{{"x":0.3,"y":0.3,"width":0.4,"height":0.4}}}},
+ "effects":{{"padding":0.4,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},
+ "background":{background},
+ "zoomRegions":[],"annotations":[],
+ "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,
+ "clipToBounds":false,"theme":"default"}},
+ "cropByClip":[null],
+ "output":{{"width":640,"height":360,"fps":30}}}}"##
+ );
+ let scene = Scene::from_json(&json).expect("scene json");
+ comp.set_live_params(live_params_from_scene(&scene));
+ comp.set_has_webcam(false);
+ comp.set_scene(Some(scene));
+ let mut cfg = Cfg::c8();
+ cfg.bg_blur = 0.0;
+ cfg.zoom = false;
+ cfg.layout_anim = false;
+ cfg.cursor = false;
+ cfg.shadow = false;
+ cfg.mblur_n = 1;
+ unsafe {
+ comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg)
+ .expect("compose_frame");
+ comp.readback_direct().expect("readback_direct").2
+ }
+ };
+ let fresh = || Compositor::new_sized(&gpu, 640, 360).expect("Compositor::new_sized");
+ let a = r##"{"kind":"gradient","angleDeg":90,"stops":["rgb(255, 0, 0)","rgb(0, 0, 255)"],"offsets":[0,1]}"##;
+ let b = r##"{"kind":"color","color":"#00ff00"}"##;
+
+ let comp = fresh();
+ let a1 = render(&comp, a);
+ assert!(comp.bg_cache.borrow().is_some(), "un fond fixe doit etre garde");
+ let a2 = render(&comp, a);
+ let b1 = render(&comp, b);
+ let a3 = render(&comp, a);
+ assert!(a1 == a2, "le fond recopie doit etre celui qui a ete dessine");
+ assert!(b1 == render(&fresh(), b), "un autre fond ne doit rien garder du precedent");
+ assert!(a3 == a1, "revenir au premier fond doit le redessiner a l'identique");
+ assert!(a1 != b1, "garde : les deux fonds different");
+
+ let aurora = r##"{"kind":"gradient","angleDeg":90,"stops":["rgb(255, 0, 0)","rgb(0, 0, 255)"],"offsets":[0,1],"motion":"aurora"}"##;
+ let comp = fresh();
+ render(&comp, aurora);
+ assert!(comp.bg_cache.borrow().is_none(), "un fond anime ne doit pas etre garde");
+ }
+
/// Le cadre de fenetre (mode 14) se dessine sur Linux comme ailleurs : `"none"` rend
/// l'image d'avant a l'octet, le theme clair ajoute une barre claire, droite comme
/// inclinee, et les deux themes different. `OPENSCREEN_FRAME_OUT` recoit les PNG.
@@ -5313,6 +5955,22 @@ mod tests {
}
}
+ /// L'ombre d'un appareil est un modele elle aussi (mode 17, `device_shadow_cb`) : comme
+ /// l'appareil, elle n'existe que dans `pipeline_models`, et le pipeline plat n'en peindrait
+ /// rien. Elle doit donc changer l'image, a plat comme incline.
+ #[test]
+ fn a_device_frame_casts_its_shadow() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized");
+ let laptop = r#","frame":"laptop""#;
+ for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] {
+ let lit = compose_framed_shadow(&comp, &gpu, laptop, rotation, 0.6);
+ let bare = compose_framed_shadow(&comp, &gpu, laptop, rotation, 0.0);
+ let shaded = lit.chunks_exact(4).zip(bare.chunks_exact(4)).filter(|(a, b)| a != b).count();
+ assert!(shaded > 2_000, "{name} : l'ombre de l'appareil ne change que {shaded} px");
+ }
+ }
+
// -----------------------------------------------------------------------
// Curseur modélisé (mode 15) : pendant de `tests/cursor_model_render.rs` (Windows).
// -----------------------------------------------------------------------
@@ -5523,6 +6181,22 @@ mod tests {
.collect()
}
+ /// Les pixels que couvre un curseur de VERRE (le cristal de Prism Glow, qui laisse voir le
+ /// contenu et le réfracte) : opaques, ou changés sur les deux teintes sans y être plus sombres
+ /// (une ombre ne fait qu'assombrir ; le verre réfracte, reflète et luit).
+ fn model_covered(on_blue: &[u8], on_orange: &[u8], bare: &[u8], bare_orange: &[u8]) -> Vec {
+ let opaque = model_opaque(on_blue, on_orange, bare);
+ (0..1280 * 720)
+ .map(|i| {
+ let lit = |on: &[u8], under: &[u8]| {
+ let (a, b) = (&on[i * 4..i * 4 + 3], &under[i * 4..i * 4 + 3]);
+ a != b && model_luma(a) >= model_luma(b) - 2.0
+ };
+ opaque[i] || (lit(on_blue, bare) && lit(on_orange, bare_orange))
+ })
+ .collect()
+ }
+
fn model_iou(a: &[bool], b: &[bool]) -> f32 {
let inter = a.iter().zip(b).filter(|(x, y)| **x && **y).count();
let union = a.iter().zip(b).filter(|(x, y)| **x || **y).count();
@@ -5592,6 +6266,34 @@ mod tests {
}
}
+ /// La trainee du curseur modelise s'accumule par `pipeline_add_models` : le pipeline plat ne
+ /// compile pas le mode 15 et n'en peindrait rien. En mouvement, flou du curseur au maximum,
+ /// le modele doit donc bien apparaitre.
+ #[test]
+ fn the_modelled_cursor_draws_its_trail() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized");
+ let (y, uv) = model_screen_planes(false);
+ let screen = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv);
+ // Traverse 60 % de la largeur en une seconde autour de t = 2 s : ~13 px par frame, donc
+ // plusieurs copies dans `accum`.
+ let moving = crate::cursor::CursorTrack::new(
+ vec![(1.5, 0.2, 0.45), (2.5, 0.8, 0.45)],
+ vec![],
+ vec![(0.0, "arrow".to_string())],
+ );
+ let json = |show: bool| {
+ model_scene_json("null", Some(true), "default", show, 3.0)
+ .replace(r#""motionBlur":0,"clickBounce""#, r#""motionBlur":1,"clickBounce""#)
+ };
+ assert!(json(true).contains(r#""motionBlur":1,"clickBounce""#), "garde : le flou du curseur");
+ let bare = compose_model(&comp, &screen, &json(false), &moving);
+ let trail = compose_model(&comp, &screen, &json(true), &moving);
+ let drawn = bare.chunks_exact(4).zip(trail.chunks_exact(4)).filter(|(a, b)| a != b).count();
+ // Taille 3 : ~1000 px de silhouette a l'arret, etales ici par la trainee.
+ assert!(drawn > 1000, "trainee du curseur modelise : {drawn} px dessines");
+ }
+
/// Chaque état garde son art et sa silhouette : posé au centre de l'écran (vu de face), le
/// modèle couvre ce que couvre le sprite plat, avec ses couleurs ; il porte une ombre en
/// l'air et suit l'inclinaison du plan. Le réglage éteint garde le sprite plat.
@@ -5681,7 +6383,8 @@ mod tests {
/// Pendant de `the_sculpted_cursors_stand_at_the_hotspot` (Windows) : la flèche et la main
/// sculptées des thèmes d'origine (`sculpt.rs`) passent par le WGSL, tiennent au hotspot, en
- /// bas à droite de lui, et portent leur ombre en l'air.
+ /// bas à droite de lui, et portent leur ombre en l'air. Le cristal de Prism Glow est de verre :
+ /// son corps compte ce qu'il couvre, et une bonne part de lui change avec la teinte de l'écran.
#[test]
fn the_sculpted_cursors_stand_at_the_hotspot() {
let Some(gpu) = gpu() else { return };
@@ -5693,6 +6396,7 @@ mod tests {
let extruded = model_scene_json("null", Some(true), "default", true, 5.0);
let hidden = model_scene_json("null", Some(true), "default", false, 5.0);
let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5));
+ let bare_orange = compose_model(&comp, &orange, &hidden, &model_track("arrow", false, 0.5));
let mut failures = Vec::new();
for state in ["arrow", "pointer"] {
let still = model_track(state, false, 0.5);
@@ -5707,7 +6411,12 @@ mod tests {
failures.push(format!("{theme}/{state}: le sprite extrude au lieu du modele"));
}
let (tip, u) = model_tip(&json, &still);
- let mask = model_opaque(&hover, &hover_b, &bare);
+ let glass = theme == "prism-glow";
+ let mask = if glass {
+ model_covered(&hover, &hover_b, &bare, &bare_orange)
+ } else {
+ model_opaque(&hover, &hover_b, &bare)
+ };
let (mut body, mut c, mut near) = (0usize, [0.0f32; 2], f32::MAX);
for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) {
let (x, y) = ((i % 1280) as f32, (i / 1280) as f32);
@@ -5735,6 +6444,124 @@ mod tests {
if shadow * 10 < body * 3 {
failures.push(format!("{theme}/{state}: pas d'ombre en l'air ({shadow} px)"));
}
+ if glass {
+ let through = (0..1280 * 720).filter(|&i| mask[i] && hover[i * 4..i * 4 + 3] != hover_b[i * 4..i * 4 + 3]).count();
+ println!("{theme}/{state} : {through} px du corps laissent voir l'ecran");
+ if through * 10 < body * 3 {
+ failures.push(format!("{theme}/{state}: le cristal ne laisse voir l'ecran que sur {through} px"));
+ }
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{failures:#?}");
+ }
+
+ /// Le métrage, puis ses flous de confidentialité, puis le curseur, net par-dessus (parité
+ /// Windows et macOS) : le cristal de Prism Glow réfracte l'image déjà floutée. Sous le flou,
+ /// des rayures fines décalées d'une colonne donnent la même image floutée ; si le verre lisait
+ /// la vidéo brute, il montrerait les rayures, et les deux rendus différeraient à travers lui.
+ #[test]
+ fn the_crystal_refracts_the_blurred_picture() {
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized");
+ // Moitié gauche grise, moitié droite (la zone floutée) rayée une colonne sur deux.
+ let (w, h) = (640u32, 360u32);
+ let stripes = |phase: u32| {
+ let y: Vec = (0..w * h)
+ .map(|i| if i % w < w / 2 { 150 } else if (i % w + phase) % 2 == 0 { 40 } else { 220 })
+ .collect();
+ FakeFrame::from_planes(&gpu, w, h, &y, &vec![128; (w * (h / 2)) as usize])
+ };
+ let (a, b) = (stripes(0), stripes(1));
+ let blur = r#""annotations":[{"id":"b","startSec":0,"endSec":10,"kind":"blur","x":0.5,"y":0.0,"w":0.5,"h":1.0,"blur":{"style":"blur","shape":"rectangle","color":"white","intensity":24,"blockSize":16}}]"#;
+ let json = |show: bool| {
+ model_scene_json("null", Some(true), "default", show, 5.0)
+ .replace(r#"/arrow.png","#, r#"/arrow.png","sculpt":"prism-glow/arrow","#)
+ .replace(r#""annotations":[]"#, blur)
+ };
+ let mut failures = Vec::new();
+ // Tout entier dans la zone, puis à cheval sur son bord.
+ for x in [0.7f32, 0.48] {
+ let track = crate::cursor::CursorTrack::new(vec![(0.0, x, 0.45), (9.0, x, 0.45)], vec![], vec![(0.0, "arrow".to_string())]);
+ let (ra, rb) = (compose_model(&comp, &a, &json(true), &track), compose_model(&comp, &b, &json(true), &track));
+ let bare = compose_model(&comp, &a, &json(false), &track);
+ model_save(&format!("glass-over-blur-{x}"), &ra);
+ let px = |img: &[u8], i: usize, c: usize| img[i * 4 + c] as i32;
+ let hidden = (0..1280 * 720).filter(|&i| (0..3).any(|c| (px(&ra, i, c) - px(&rb, i, c)).abs() > 24)).count();
+ // Le serti net du curseur : des sauts francs, entre voisins, de ce qu'il change.
+ let d = |i: usize, c: usize| px(&ra, i, c) - px(&bare, i, c);
+ let sharp = (0..1280 * 720 - 1)
+ .filter(|&i| i % 1280 < 1279 && (0..3).any(|c| (d(i, c) - d(i + 1, c)).abs() > 60))
+ .count();
+ println!("cristal en x = {x} : {sharp} bords francs, {hidden} px qui suivent les rayures cachées");
+ if sharp < 50 {
+ failures.push(format!("x = {x} : {sharp} bords francs, le curseur n'est pas net par-dessus le flou"));
+ }
+ if hidden > 0 {
+ failures.push(format!("x = {x} : {hidden} px montrent les rayures cachées sous le flou"));
+ }
+ }
+ assert!(failures.is_empty(), "{failures:#?}");
+ }
+
+ /// Les thèmes cerclés gardent le trait de leur dessin (`design/cursors/`) : sur la
+ /// fleche comme sur la main, le modele est pour une bonne part de la couleur du trait (le
+ /// plateau, le jonc, les rainures entre les doigts), autour de la couleur du corps, et montre
+ /// ce qu'il porte devant : l'etoile jaune de Star Sprout, le calque et les tirets jaunes de la
+ /// fleche de Pop Coral, les tirets corail de sa main. Les parts comptent les pixels francs :
+ /// ceux que l'antialiasing mêle aux bords n'entrent dans aucune.
+ #[test]
+ fn the_rimmed_models_keep_the_outline_of_their_art() {
+ type Rgb = [i32; 3];
+ let navy = |[r, g, b]: Rgb| b > r + 20 && r < 110 && g < 130;
+ let black = |[r, g, b]: Rgb| r.max(g).max(b) < 70;
+ let mint = |[r, g, b]: Rgb| g > 200 && g > r + 15 && b > 150;
+ let ivory = |[r, g, b]: Rgb| r > 200 && g > 190 && b > 160 && r - b < 70;
+ let coral = |[r, g, b]: Rgb| r > 200 && g < 150 && b < 140;
+ let yellow = |[r, g, b]: Rgb| r > 200 && g > 150 && b < 120;
+ // (thème, état, trait, corps, ornement, parts minimales du trait, du corps, de l'ornement)
+ let cases: [(&str, &str, &dyn Fn(Rgb) -> bool, &dyn Fn(Rgb) -> bool, &dyn Fn(Rgb) -> bool, [f32; 3]); 6] = [
+ ("studio-ink", "arrow", &black, &ivory, &|_| false, [0.3, 0.15, 0.0]),
+ ("studio-ink", "pointer", &black, &ivory, &|_| false, [0.2, 0.3, 0.0]),
+ ("pop-coral", "arrow", &navy, &coral, &yellow, [0.2, 0.2, 0.05]),
+ ("pop-coral", "pointer", &navy, &yellow, &coral, [0.18, 0.3, 0.02]),
+ ("star-sprout", "arrow", &navy, &mint, &yellow, [0.25, 0.2, 0.03]),
+ ("star-sprout", "pointer", &navy, &ivory, &yellow, [0.25, 0.2, 0.03]),
+ ];
+ let Some(gpu) = gpu() else { return };
+ let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized");
+ let (y, uv) = model_screen_planes(false);
+ let blue = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv);
+ let (y, uv) = model_screen_planes(true);
+ let orange = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv);
+ let extruded = model_scene_json("null", Some(true), "default", true, 5.0);
+ let hidden = model_scene_json("null", Some(true), "default", false, 5.0);
+ let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5));
+ let mut failures = Vec::new();
+ for (theme, state, rim, body, extra, [min_rim, min_body, min_extra]) in cases {
+ let still = model_track(state, false, 0.5);
+ let json = extruded
+ .replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","sculpt":"{theme}/{state}","#));
+ let (hover, hover_b) = (compose_model(&comp, &blue, &json, &still), compose_model(&comp, &orange, &json, &still));
+ let mask = model_opaque(&hover, &hover_b, &bare);
+ let (mut total, mut counts) = (0usize, [0usize; 3]);
+ for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) {
+ let p = [hover[i * 4] as i32, hover[i * 4 + 1] as i32, hover[i * 4 + 2] as i32];
+ total += 1;
+ for (k, class) in [rim, body, extra].iter().enumerate() {
+ counts[k] += usize::from(class(p));
+ }
+ }
+ let [rim_share, body_share, extra_share] = counts.map(|k| k as f32 / total.max(1) as f32);
+ println!("{theme}/{state} : {total} px, trait {rim_share:.3}, corps {body_share:.3}, ornement {extra_share:.3}");
+ if !(min_rim..0.7).contains(&rim_share) {
+ failures.push(format!("{theme}/{state}: {rim_share:.3} de trait, il a disparu ou tout mange"));
+ }
+ if body_share < min_body {
+ failures.push(format!("{theme}/{state}: {body_share:.3} de couleur du corps"));
+ }
+ if extra_share < min_extra {
+ failures.push(format!("{theme}/{state}: {extra_share:.3} d'ornement"));
}
}
assert!(failures.is_empty(), "{failures:#?}");
diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs
index b00a288ee..a9a571e98 100644
--- a/crates/compositor/src/compositor_macos.rs
+++ b/crates/compositor/src/compositor_macos.rs
@@ -261,9 +261,14 @@ pub struct Compositor {
scene: RefCell>,
cursor: RefCell >,
cursor_time: RefCell >,
+ /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`).
+ footage: std::cell::Cell >,
timeline_time: RefCell >,
/// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`.
programme_time: RefCell >,
+ /// The frames of cameras 1..=3 (`set_extra_camera_frames`), as addresses (0 = none) so
+ /// the compositor's auto traits stay what they were without the raw pointers.
+ extra_camera_frames: std::cell::Cell<[usize; crate::camera_layers::MAX_EXTRA_CAMERAS]>,
live_params: RefCell,
metal_texture_cache: CVMetalTextureCache,
/// Dernier command buffer soumis, gardé pour pouvoir l'attendre AU MOMENT où le CPU lit
@@ -316,6 +321,17 @@ pub struct Compositor {
pipeline_fs_tex: metal::RenderPipelineState,
/// `vs_main` + `ps_main` en additif : les échantillons de traînée du curseur.
pipeline_add: metal::RenderPipelineState,
+ /// `pipeline_main` et `pipeline_add` compilés AVEC les modèles 3D (`LAYER_MODELS`, cf.
+ /// l'en-tête de `shaders.metal`). Les deux autres ne les ont pas ; `draw_layer` bascule sur
+ /// ceux-ci le temps du draw d'un modèle (`LayerCB::needs_models`).
+ pipeline_main_models: metal::RenderPipelineState,
+ pipeline_add_models: metal::RenderPipelineState,
+ /// La passe en cours a été ouverte sur `pipeline_add` (cf. `begin_pass`) : dit à
+ /// `draw_layer` laquelle des deux variantes « modèles » prendre.
+ layer_add: std::cell::Cell,
+ /// Le dernier fond fixe composé et sa clé (`BackgroundKey`) : recopié dans le RT tant
+ /// que la clé tient, au lieu d'être redessiné.
+ bg_cache: RefCell>,
/// Buffer d'accumulation ISOLÉ (transparent) pour la traînée. Accumuler directement sur
/// le RT reviendrait à AJOUTER du blanc à ce qui est déjà dessous : sur un fond clair,
/// le curseur disparaît. Même raisonnement que côté D3D11.
@@ -537,6 +553,18 @@ enum Blend {
Add,
}
+/// `shaders.metal` avec les modèles 3D : la source dont seuls les pipelines de ces calques sont
+/// tirés (cf. son en-tête).
+fn msl_source_with_models() -> String {
+ format!("#define LAYER_MODELS 1\n{}", include_str!("shaders.metal"))
+}
+
+/// Le fond tel que la passe de fond (et son flou) l'a laissé dans le RT, et la clé qui le décrit.
+struct BgCache {
+ key: crate::frame_geometry::BackgroundKey,
+ tex: metal::Texture,
+}
+
/// Un pipeline state à une seule pièce jointe couleur.
fn make_pipeline(
device: &metal::Device,
@@ -661,6 +689,25 @@ impl Compositor {
metal::MTLPixelFormat::RGBA8Unorm,
Blend::Add,
)?;
+ let library_models = device
+ .new_library_with_source(&msl_source_with_models(), &metal::CompileOptions::new())
+ .map_err(|e| anyhow!("MTLDevice::new_library_with_source (modèles) a échoué : {e}"))?;
+ let pipeline_main_models = make_pipeline(
+ device,
+ &library_models,
+ "vs_main",
+ "ps_main",
+ metal::MTLPixelFormat::RGBA8Unorm,
+ Blend::Over,
+ )?;
+ let pipeline_add_models = make_pipeline(
+ device,
+ &library_models,
+ "vs_main",
+ "ps_main",
+ metal::MTLPixelFormat::RGBA8Unorm,
+ Blend::Add,
+ )?;
let pipeline_kdown = make_pipeline(
device, &library, "vs_fs", "ps_kawase_down",
metal::MTLPixelFormat::RGBA8Unorm, Blend::Replace,
@@ -682,8 +729,10 @@ impl Compositor {
scene: RefCell::new(None),
cursor: RefCell::new(None),
cursor_time: RefCell::new(None),
+ footage: std::cell::Cell::new(None),
timeline_time: RefCell::new(None),
programme_time: RefCell::new(None),
+ extra_camera_frames: std::cell::Cell::new([0; crate::camera_layers::MAX_EXTRA_CAMERAS]),
live_params: RefCell::new(LiveParams::default()),
metal_texture_cache: cache,
last_cmd: RefCell::new(None),
@@ -703,6 +752,10 @@ impl Compositor {
pipeline_fs_uv,
pipeline_fs_tex,
pipeline_add,
+ pipeline_main_models,
+ pipeline_add_models,
+ layer_add: std::cell::Cell::new(false),
+ bg_cache: RefCell::new(None),
accum,
trail,
blur_half,
@@ -761,6 +814,7 @@ impl Compositor {
blur_quarter,
blur_eighth,
ann_copy,
+ bg_cache: RefCell::new(None),
..self
})
}
@@ -786,6 +840,9 @@ impl Compositor {
pub fn set_scene(&self, s: Option) {
*self.scene.borrow_mut() = s;
+ // A new scene can drop the regions that drew an extra camera: forget its frames so a
+ // stale pointer is never drawn before the next `set_extra_camera_frames`.
+ self.extra_camera_frames.set([0; crate::camera_layers::MAX_EXTRA_CAMERAS]);
}
pub fn set_cursor(&self, track: crate::cursor::CursorTrack) {
@@ -887,6 +944,26 @@ impl Compositor {
/// IOSurface, pas par pointeur Rust. `flush()` est donc la vidange elle-même.
pub fn clear_srv_cache(&self) {
self.metal_texture_cache.flush();
+ // The extra cameras' frames belong to the decoders just closed: forget them too, so
+ // a stale pointer is never read before the next `set_extra_camera_frames`.
+ self.extra_camera_frames.set([0; crate::camera_layers::MAX_EXTRA_CAMERAS]);
+ }
+
+ /// Frames for cameras 1..=3 (index 0 = scene camera 1). A null or missing entry means that
+ /// camera is not drawn this frame. The compositor keeps the pointers and reads them in every
+ /// `compose_frame` until the next call, so they must stay valid until then.
+ pub unsafe fn set_extra_camera_frames(&self, frames: &[*const AVFrame]) {
+ let mut slots = [0usize; crate::camera_layers::MAX_EXTRA_CAMERAS];
+ for (slot, frame) in slots.iter_mut().zip(frames) {
+ *slot = *frame as usize;
+ }
+ self.extra_camera_frames.set(slots);
+ }
+
+ /// The frame set for scene camera `camera` (1..=3), null when there is none.
+ fn extra_camera_frame(&self, camera: usize) -> *const AVFrame {
+ let slots = self.extra_camera_frames.get();
+ camera.checked_sub(1).and_then(|k| slots.get(k).copied()).unwrap_or(0) as *const AVFrame
}
/// Les verbes de dessin, côté Metal. Mêmes noms et mêmes paramètres que leurs
@@ -909,7 +986,20 @@ impl Compositor {
enc.set_fragment_texture(0, Some(y));
enc.set_fragment_texture(1, Some(uv));
}
- enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4);
+ // Un modèle 3D passe, le temps de son draw, par la variante « modèles » du pipeline de la
+ // passe, la seule à le compiler ; le pipeline plat reprend ensuite la main.
+ if cb.needs_models() {
+ let (models, flat) = if self.layer_add.get() {
+ (&self.pipeline_add_models, &self.pipeline_add)
+ } else {
+ (&self.pipeline_main_models, &self.pipeline_main)
+ };
+ enc.set_render_pipeline_state(models);
+ enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4);
+ enc.set_render_pipeline_state(flat);
+ } else {
+ enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4);
+ }
}
/// Quad de couleur pleine / gradient / ombre — tout ce qui n'échantillonne pas la vidéo.
@@ -1087,13 +1177,15 @@ impl Compositor {
programme_t: f32,
) -> Result<()> {
let full = [0.0, 0.0, 1.0, 1.0];
- self.draw_image_in(enc, path, full, [0.0, 0.0], 0.0, output_aspect, motion, programme_t)
+ let (aspect, t) = (output_aspect, programme_t);
+ self.draw_image_in(enc, path, full, [0.0, 0.0], 0.0, aspect, motion, t, 0.0)
}
/// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins
/// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop
/// « cover » se calcule contre la zone qu'on remplit. `motion` anime l'image au temps
- /// programme `programme_t` ; la bulle passe `WallpaperMotion::None`.
+ /// programme `programme_t` ; la bulle passe `WallpaperMotion::None`. `transparency` =
+ /// `layer_fx.x` (0 = opaque): a camera layer's background box fades with the layer.
#[allow(clippy::too_many_arguments)]
unsafe fn draw_image_in(
&self,
@@ -1105,6 +1197,7 @@ impl Compositor {
output_aspect: f32,
motion: WallpaperMotion,
programme_t: f32,
+ transparency: f32,
) -> Result<()> {
let (tex, iw, ih) = self.cached_image(path)?;
let ai = iw as f32 / ih.max(1) as f32;
@@ -1128,6 +1221,7 @@ impl Compositor {
mode: 6.0,
fx: [0.0, 0.0, anim[0], anim[1]],
mb,
+ layer_fx: [transparency, 0.0, 0.0, 0.0],
..Default::default()
},
);
@@ -1146,6 +1240,8 @@ impl Compositor {
///
/// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit épouser ses coins arrondis,
/// sinon un rectangle déborde derrière la caméra.
+ ///
+ /// `transparency` = the camera layer's `layer_fx.x`: the box fades with the camera.
unsafe fn draw_webcam_bg(
&self,
enc: &metal::RenderCommandEncoderRef,
@@ -1153,14 +1249,17 @@ impl Compositor {
dst: [f32; 4],
quad_px: [f32; 2],
radius_px: f32,
+ transparency: f32,
) {
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
+ let layer_fx = [transparency, 0.0, 0.0, 0.0];
let solid = |color: [f32; 4]| LayerCB {
dst,
quad_px,
radius_px,
mode: 1.0,
color,
+ layer_fx,
..Default::default()
};
match bg {
@@ -1178,6 +1277,7 @@ impl Compositor {
quad_px,
radius_px,
fx: [a.sin(), -a.cos(), 0.0, 0.0],
+ layer_fx,
..crate::frame_geometry::gradient_layer(stops, offsets, BLACK)
},
);
@@ -1188,7 +1288,9 @@ impl Compositor {
let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 };
let still = WallpaperMotion::None;
if let Err(e) =
- self.draw_image_in(enc, path, dst, quad_px, radius_px, aspect, still, 0.0)
+ self.draw_image_in(
+ enc, path, dst, quad_px, radius_px, aspect, still, 0.0, transparency,
+ )
{
eprintln!("[compositor] fond webcam \"{path}\" : {e:#}");
self.draw_solid(enc, &solid(BLACK));
@@ -1417,6 +1519,37 @@ impl Compositor {
}
+ /// Le fond gardé (`bg_cache`) : copie le RT dans `tex`, ou `tex` dans le RT (`into_rt`).
+ unsafe fn blit_background(&self, cmd: &metal::CommandBufferRef, tex: &metal::Texture, into_rt: bool) {
+ let (src, dst) = if into_rt { (tex, &self.rt) } else { (&self.rt, tex) };
+ let blit = cmd.new_blit_command_encoder();
+ blit.copy_from_texture(
+ src, 0, 0,
+ metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ metal::MTLSize { width: self.render_w as u64, height: self.render_h as u64, depth: 1 },
+ dst, 0, 0,
+ metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ );
+ blit.end_encoding();
+ }
+
+ /// Fige l'image composée dans `ann_copy` (mip 0), qu'y lisent les flous (mips dérivés,
+ /// `mips`) et le cristal de Prism Glow.
+ unsafe fn snapshot_rt(&self, cmd: &metal::CommandBufferRef, mips: bool) {
+ let blit = cmd.new_blit_command_encoder();
+ blit.copy_from_texture(
+ &self.rt, 0, 0,
+ metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ metal::MTLSize { width: self.render_w as u64, height: self.render_h as u64, depth: 1 },
+ &self.ann_copy, 0, 0,
+ metal::MTLOrigin { x: 0, y: 0, z: 0 },
+ );
+ if mips {
+ blit.generate_mipmaps(&self.ann_copy);
+ }
+ blit.end_encoding();
+ }
+
/// Annotations : calque le plus haut, ancré sur `s_ann` — le rect écran SANS ZOOM, le
/// conteneur que reçoit l'overlay web. Port de `compositor_windows::draw_annotations`.
///
@@ -1425,6 +1558,9 @@ impl Compositor {
/// grossir les sous-titres sous un zoom (issue #179, puis #397 sur Linux). L'arithmétique
/// elle-même vit dans `frame_geometry::annotation_dst_in`, partagée par les trois backends.
/// Le flou, lui, se place par `g.privacy_mask` : un masque doit rester sur ce qu'il cache.
+ ///
+ /// `privacy` : les flous de confidentialité seuls, dessinés sur le métrage avant le curseur ;
+ /// sinon toutes les autres annotations, le calque le plus haut.
unsafe fn draw_annotations(
&self,
cmd: &metal::CommandBufferRef,
@@ -1432,30 +1568,21 @@ impl Compositor {
t: f32,
s_ann: [f32; 4],
g: &crate::frame_geometry::FrameGeometry,
+ privacy: bool,
) -> Result<()> {
let Some(scene) = scene else { return Ok(()) };
- if scene.annotations.is_empty() {
- return Ok(());
- }
let (rw, rh) = (self.render_w as f32, self.render_h as f32);
let visible = |a: &crate::scene::SceneAnnotation| {
- t >= a.start_sec as f32 && t < a.end_sec as f32
+ t >= a.start_sec as f32 && t < a.end_sec as f32 && (a.kind == "blur") == privacy
};
+ if !scene.annotations.iter().any(visible) {
+ return Ok(());
+ }
// UNE seule recopie pour toutes les annotations flou de la frame : leur lecture doit
// voir l'image composée SANS les flous eux-mêmes, sinon deux zones qui se recouvrent
// s'échantillonneraient l'une l'autre selon l'ordre de dessin.
- if scene.annotations.iter().any(|a| a.kind == "blur" && visible(a)) {
- let blit = cmd.new_blit_command_encoder();
- blit.copy_from_texture(
- &self.rt, 0, 0,
- metal::MTLOrigin { x: 0, y: 0, z: 0 },
- metal::MTLSize { width: rw as u64, height: rh as u64, depth: 1 },
- &self.ann_copy, 0, 0,
- metal::MTLOrigin { x: 0, y: 0, z: 0 },
- );
- // Seul le mip 0 est rempli ; le GPU dérive le reste.
- blit.generate_mipmaps(&self.ann_copy);
- blit.end_encoding();
+ if privacy {
+ self.snapshot_rt(cmd, true);
}
let enc = self.begin_pass(cmd, &self.rt, None, &self.pipeline_main)?;
@@ -1585,6 +1712,13 @@ impl Compositor {
if text.content.trim().is_empty() {
continue;
}
+ // The desk label shows only while the camera is covered: skip the rest of
+ // its section before rasterizing anything.
+ if crate::text_anim::is_desk_cover(text.animation.as_deref())
+ && g.webcam_cover <= 0.0
+ {
+ continue;
+ }
let spec = crate::text::TextSpec {
content: text.content.clone(),
color: parse_hex(&text.color).unwrap_or([1.0, 1.0, 1.0, 1.0]),
@@ -1618,10 +1752,11 @@ impl Compositor {
}) else {
continue;
};
- let anim = crate::text_anim::text_animation_state(
+ let anim = crate::text_anim::annotation_text_state(
text.animation.as_deref(),
(t - a.start_sec as f32) * 1000.0,
((a.end_sec - a.start_sec) * 1000.0) as f32,
+ g.webcam_cover,
);
let anim_px = rh / crate::text_anim::ANIMATION_REFERENCE_HEIGHT;
let (mut ax, mut ay, mut aw, mut ah) = (
@@ -2058,6 +2193,8 @@ impl Compositor {
ca.set_store_action(metal::MTLStoreAction::Store);
let enc = cmd.new_render_command_encoder(&desc);
enc.set_render_pipeline_state(pipeline);
+ // La famille du pipeline de calque de cette passe, pour `draw_layer`.
+ self.layer_add.set(std::ptr::eq(pipeline, &self.pipeline_add));
Ok(enc)
}
@@ -2066,7 +2203,8 @@ impl Compositor {
/// `frame_geometry::cursor_sprite_cb`, partagée avec Windows et Linux.
///
/// Avec `model`, le même sprite extrudé (mode 15, `cursor_model_cb`) : le sprite en
- /// texture(2), son champ de distance en texture(4).
+ /// texture(2), son champ de distance en texture(4), l'enregistrement que l'appelant a lié en
+ /// texture(0) et (1).
#[allow(clippy::too_many_arguments)]
unsafe fn draw_cursor_sprite(
&self,
@@ -2077,21 +2215,22 @@ impl Compositor {
sprite: &crate::scene::SceneCursorSprite,
clip: [f32; 4],
model: Option,
- ) -> Result<()> {
+ ) -> Result> {
let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?;
// Sans champ de distance, repli sur le sprite plat plutôt qu'aucun curseur. Parité Linux.
if let Some(pose) = model {
match self.cursor_sdf(sprite.path.as_str()) {
Ok((sdf, shape)) => {
let shape = crate::frame_geometry::model_shape(sprite, shape);
- if let Some(cb) = crate::frame_geometry::cursor_model_cb(
+ let Some(cb) = crate::frame_geometry::cursor_model_cb(
placement, size_px, pose, shape, a, clip,
- ) {
- enc.set_fragment_texture(2, Some(&tex));
- enc.set_fragment_texture(4, Some(&sdf));
- self.draw_solid(enc, &cb);
- }
- return Ok(());
+ ) else {
+ return Ok(None);
+ };
+ enc.set_fragment_texture(2, Some(&tex));
+ enc.set_fragment_texture(4, Some(&sdf));
+ self.draw_solid(enc, &cb);
+ return Ok(Some(cb.dst));
}
Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path),
}
@@ -2109,7 +2248,7 @@ impl Compositor {
);
enc.set_fragment_texture(2, Some(&tex));
self.draw_solid(enc, &cb);
- Ok(())
+ Ok(Some(cb.dst))
}
/// Curseur thématisé : le sprite de l'état courant, sinon la flèche, sinon rien.
@@ -2132,14 +2271,17 @@ impl Compositor {
a: f32,
clip: [f32; 4],
model: Option,
- ) {
- let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow"));
- if let Some(sprite) = sprite {
- if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model)
- {
- eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path);
- }
- }
+ ) -> Option<[f32; 4]> {
+ let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow"))?;
+ self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model).unwrap_or_else(|e| {
+ eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path);
+ None
+ })
+ }
+
+ /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`).
+ pub fn footage_quad(&self) -> Option {
+ self.footage.get()
}
/// Compose la frame : fond, ombre écran, écran, ombre caméra, caméra — puis miroir
@@ -2211,6 +2353,7 @@ impl Compositor {
timeline_t_override: *self.timeline_time.borrow(),
programme_time: *self.programme_time.borrow(),
});
+ self.footage.set(Some(g.footage_quad([rw, rh])));
let cmd_buf = self.gpu.context.new_command_buffer();
// Profondeur de champ : pyramide remplie seulement sur une frame inclinée qui la lit,
@@ -2220,46 +2363,75 @@ impl Compositor {
} else {
None
};
- let enc = self.begin_pass(
- cmd_buf,
- &self.rt,
- Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)),
- &self.pipeline_main,
- )?;
- // Les deux plans écran restent liés par défaut : les quads de couleur ne les
- // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux,
- // le font.
- enc.set_fragment_texture(0, Some(&sy));
- enc.set_fragment_texture(1, Some(&suv));
-
- // --- fond --- (parité `compositor_windows.rs`, section « fond »)
- match scene_ref.as_ref().map(|s| s.background.clone()) {
- Some(SceneBackground::Color { color }) => {
- let c = parse_hex(&color).unwrap_or(lp.bg_color);
- self.draw_solid(
- enc,
- &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() },
- );
- }
- Some(SceneBackground::Gradient { angle_deg, stops, offsets, motion }) => {
- let a = angle_deg.to_radians();
- let (anim, mb) =
- crate::frame_geometry::wallpaper_motion_slots(motion, g.programme_t, rw / rh);
- self.draw_solid(
- enc,
- &LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
- fx: [a.sin(), -a.cos(), anim[0], anim[1]],
- mb,
- ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color)
- },
- );
+ // Le fond de la frame d'avant, flou compris, s'il est celui qu'on demande
+ // (`BackgroundKey`) : recopié au lieu d'être redessiné.
+ let bg_desc = scene_ref.as_ref().map(|s| &s.background);
+ let bg_blur = scene_ref.as_ref().map_or(0.0, |s| s.effects.blur);
+ let bg_cached = self
+ .bg_cache
+ .borrow()
+ .as_ref()
+ .is_some_and(|c| c.key.matches(bg_desc, bg_blur, lp.bg_color, [rw, rh]));
+ if bg_cached {
+ if let Some(c) = self.bg_cache.borrow().as_ref() {
+ self.blit_background(cmd_buf, &c.tex, true);
}
- Some(SceneBackground::Image { path, motion }) => {
- // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait
- // un chemin cassé.
- if let Err(e) = self.draw_image_bg(enc, &path, rw / rh, motion, g.programme_t) {
- eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}");
+ } else {
+ let enc = self.begin_pass(
+ cmd_buf,
+ &self.rt,
+ Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)),
+ &self.pipeline_main,
+ )?;
+ // Les deux plans écran restent liés par défaut : les quads de couleur ne les
+ // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux,
+ // le font.
+ enc.set_fragment_texture(0, Some(&sy));
+ enc.set_fragment_texture(1, Some(&suv));
+
+ // --- fond --- (parité `compositor_windows.rs`, section « fond »)
+ // Faux si l'image du fond n'a pas pu se charger : son repli n'est pas gardé.
+ let mut bg_drawn = true;
+ match scene_ref.as_ref().map(|s| s.background.clone()) {
+ Some(SceneBackground::Color { color }) => {
+ let c = parse_hex(&color).unwrap_or(lp.bg_color);
+ self.draw_solid(
+ enc,
+ &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() },
+ );
+ }
+ Some(SceneBackground::Gradient { angle_deg, stops, offsets, motion }) => {
+ let a = angle_deg.to_radians();
+ let (anim, mb) =
+ crate::frame_geometry::wallpaper_motion_slots(motion, g.programme_t, rw / rh);
+ self.draw_solid(
+ enc,
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ fx: [a.sin(), -a.cos(), anim[0], anim[1]],
+ mb,
+ ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color)
+ },
+ );
+ }
+ Some(SceneBackground::Image { path, motion }) => {
+ // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait
+ // un chemin cassé.
+ if let Err(e) = self.draw_image_bg(enc, &path, rw / rh, motion, g.programme_t) {
+ eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}");
+ bg_drawn = false;
+ self.draw_solid(
+ enc,
+ &LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ mode: 1.0,
+ color: lp.bg_color,
+ ..Default::default()
+ },
+ );
+ }
+ }
+ None => {
self.draw_solid(
enc,
&LayerCB {
@@ -2271,25 +2443,34 @@ impl Compositor {
);
}
}
- None => {
- self.draw_solid(
- enc,
- &LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
- mode: 1.0,
- color: lp.bg_color,
- ..Default::default()
- },
- );
- }
- }
- // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient
- // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un
- // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici.
- enc.end_encoding();
- if let Some(blur) = scene_ref.as_ref().map(|s| s.effects.blur) {
- self.blur_bg(cmd_buf, blur)?;
+ // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient
+ // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un
+ // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici.
+ enc.end_encoding();
+ if let Some(blur) = scene_ref.as_ref().map(|s| s.effects.blur) {
+ self.blur_bg(cmd_buf, blur)?;
+ }
+ // Pas de cache pour un fond animé (`of` rend `None`).
+ let key = bg_drawn
+ .then(|| crate::frame_geometry::BackgroundKey::of(bg_desc, bg_blur, lp.bg_color, [rw, rh]))
+ .flatten();
+ if let Some(key) = key {
+ // L'ancienne texture est reprise : `resized` vide le cache, sa taille est la bonne.
+ let old = self.bg_cache.borrow_mut().take().map(|c| c.tex);
+ let tex = old.unwrap_or_else(|| {
+ make_texture(
+ &self.gpu.device,
+ metal::MTLPixelFormat::RGBA8Unorm,
+ self.render_w,
+ self.render_h,
+ metal::MTLStorageMode::Private,
+ metal::MTLTextureUsage::ShaderRead,
+ )
+ });
+ self.blit_background(cmd_buf, &tex, false);
+ *self.bg_cache.borrow_mut() = Some(BgCache { key, tex });
+ }
}
// --- écran : ombre puis vidéo ---
let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh];
@@ -2400,6 +2581,11 @@ impl Compositor {
enc.end_encoding();
+ // --- flous de confidentialité : sur le métrage, AVANT le curseur (parité Windows). Le
+ // curseur reste net par-dessus, et le cristal de Prism Glow, qui réfracte l'image
+ // composée, n'y voit que des pixels déjà floutés.
+ self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g, true)?;
+
// --- curseur --- (parité `compositor_windows.rs`, section « curseur custom »)
if let Some(track) = cursor_ref.as_ref() {
let plan = crate::frame_geometry::plan_cursor(
@@ -2422,6 +2608,12 @@ impl Compositor {
.map(|s| s.cursor.cursor_sprites.clone())
.unwrap_or_default();
let kind = plan.cursor_type.as_deref();
+ // Le cristal de Prism Glow réfracte l'image telle qu'elle est composée à cet
+ // instant, flous compris : sa copie en texture(5), une fois pour toutes les copies
+ // de la traînée (`ann_copy` est libre, les flous l'ont déjà lue).
+ if plan.glass {
+ self.snapshot_rt(cmd_buf, false);
+ }
// L'impact des clics (mode 16, sans texture), posé sur l'écran SOUS le curseur.
if !plan.impacts.is_empty() {
let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?;
@@ -2432,6 +2624,9 @@ impl Compositor {
}
if plan.taps <= 1 {
let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?;
+ if plan.glass {
+ e.set_fragment_texture(5, Some(&self.ann_copy));
+ }
self.draw_cur_themed(
e,
&sprites,
@@ -2454,11 +2649,17 @@ impl Compositor {
Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)),
&self.pipeline_add,
)?;
+ if plan.glass {
+ e.set_fragment_texture(5, Some(&self.ann_copy));
+ }
+ // L'union des quads peints dans `accum` (x0, y0, x1, y1 en fractions de la
+ // sortie) : la seule région que la recopie doit relire.
+ let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
for k in 0..plan.taps {
let f = k as f32 / (plan.taps - 1) as f32;
let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps);
e.set_blend_color(w, w, w, w);
- self.draw_cur_themed(
+ let drawn = self.draw_cur_themed(
e,
&sprites,
kind,
@@ -2468,12 +2669,36 @@ impl Compositor {
plan.clip,
plan.model,
);
+ if let Some(d) = drawn {
+ bounds = [
+ bounds[0].min(d[0]),
+ bounds[1].min(d[1]),
+ bounds[2].max(d[0] + d[2]),
+ bounds[3].max(d[1] + d[3]),
+ ];
+ }
}
e.end_encoding();
let c = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_fs_tex)?;
c.set_fragment_texture(0, Some(&self.accum));
- c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3);
+ // Rien n'a été peint dans `accum` hors des quads de la traînée : la recopie
+ // s'y borne au lieu de repasser sur toute la sortie (0,5 ms par frame 1080p
+ // mesurée sur Linux, Radeon 610M).
+ let (w, h) = (self.render_w as f32, self.render_h as f32);
+ let x0 = (bounds[0] * w).floor().clamp(0.0, w) as u64;
+ let y0 = (bounds[1] * h).floor().clamp(0.0, h) as u64;
+ let x1 = (bounds[2] * w).ceil().clamp(0.0, w) as u64;
+ let y1 = (bounds[3] * h).ceil().clamp(0.0, h) as u64;
+ if x1 > x0 && y1 > y0 {
+ c.set_scissor_rect(metal::MTLScissorRect {
+ x: x0,
+ y: y0,
+ width: x1 - x0,
+ height: y1 - y0,
+ });
+ c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3);
+ }
c.end_encoding();
}
}
@@ -2481,105 +2706,150 @@ impl Compositor {
// --- caméra : ombre PiP puis vidéo ---
let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?;
- if let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) {
- let [cu0, cv0, cu1, cv1] = crate::frame_geometry::webcam_source_rect(
- [wcw, wch],
- [wtw as f32, wth as f32],
- scene_ref.as_ref().and_then(|scene| scene.layout.webcam_crop),
- g.w_px[0] / g.w_px[1].max(0.0001),
- );
- let (u0, u1) = if lp.webcam_mirror { (cu1, cu0) } else { (cu0, cu1) };
- let webcam_is_block = matches!(
- g.scene_preset.as_deref(),
- Some("dual-frame") | Some("vertical-stack")
- );
- // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
- // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
- // invisible en détourage, donc tant que rien n'a été segmenté on dessine la
- // piste telle quelle. C'est aussi ce qui rend le premier lancement gracieux.
- let mask = self.webcam_mask.borrow();
- let effect = scene_ref
- .as_ref()
- .and_then(|s| s.webcam_effect.as_ref())
- .filter(|_| mask.is_some())
- .map(|e| (e.shader_code(), e))
- .filter(|(code, _)| *code > 0.0);
-
- // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
- // ombre portée par un rectangle invisible se lit comme un artefact. Le test porte
- // sur le code de la SCÈNE et non sur celui envoyé au shader : le fond personnalisé
- // part lui aussi en détourage ci-dessous, mais sa bulle, elle, est bien peinte et
- // garde donc son ombre.
- let is_cutout = matches!(effect, Some((code, _)) if code == 1.0);
- if cfg.shadow && !webcam_is_block && !is_cutout && g.shape_fade > 0.0 {
- self.draw_shadow(
- enc,
- g.w_dst,
- g.w_px,
- g.w_radius,
- WEBCAM_SHADOW_SPREAD_FRAC * g.frame_min_px,
- [0.0, WEBCAM_SHADOW_OFFSET_FRAC * g.frame_min_px],
- WEBCAM_SHADOW_OPACITY * g.shape_fade,
+ // Camera 0's crop, mirror and desk turn as camera settings, plus its homography
+ // (`Scene::camera(0)`): with one, `src` is not read and the crop is dropped.
+ let cam0 = g.camera0_settings(scene_ref.as_ref());
+ let webcam_is_block = matches!(
+ g.scene_preset.as_deref(),
+ Some("dual-frame") | Some("vertical-stack")
+ );
+ // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
+ // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
+ // invisible en détourage, donc tant que rien n'a été segmenté on dessine la
+ // piste telle quelle. C'est aussi ce qui rend le premier lancement gracieux.
+ let mask = self.webcam_mask.borrow();
+ let effect = scene_ref
+ .as_ref()
+ .and_then(|s| s.webcam_effect.as_ref())
+ .filter(|_| mask.is_some())
+ .map(|e| (e.shader_code(), e))
+ .filter(|(code, _)| *code > 0.0);
+ // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
+ // ombre portée par un rectangle invisible se lit comme un artefact. Le test porte
+ // sur le code de la SCÈNE et non sur celui envoyé au shader : le fond personnalisé
+ // part lui aussi en détourage ci-dessous, mais sa bulle, elle, est bien peinte et
+ // garde donc son ombre.
+ let is_cutout = matches!(effect, Some((code, _)) if code == 1.0);
+ // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra
+ // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement
+ // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de
+ // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et
+ // images y tombaient sur du noir, et le défaut EST une image. L'ordre est imposé :
+ // ombre, puis fond, puis caméra. `custom_bg` = Some(background) in that mode.
+ let (effect_code, blur_intensity, custom_bg) = match effect {
+ Some((code, e)) if code > 2.5 => (1.0, 0.0, Some(e.background.as_ref())),
+ Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), None),
+ None => (0.0, 0.0, None),
+ };
+ // Metal tolère l'index 3 non lié tant que `fx.z` reste à 0 : la branche n'est
+ // pas prise, la texture n'est pas échantillonnée. Dès qu'il monte, elle doit
+ // l'être sur TOUT draw capable de la prendre — ici seul celui de la caméra 0. L'état
+ // d'un encodeur est rémanent, donc lier avant le draw suffit, et l'ombre puis le
+ // fond qui précèdent sont en modes 1/2/5/6, que `ps_main` garde hors de la branche
+ // (`mode < 0.5`). The extra cameras draw with `fx.z = 0` and never read it.
+ //
+ // Pas de déliaison après coup, contrairement au chemin Windows qui remet le slot
+ // t3 à `None` : cet état meurt avec l'encodeur, et les annotations en ouvrent un
+ // autre. Il n'y a rien sur quoi fuir.
+ if let Some(m) = mask.as_ref() {
+ enc.set_fragment_texture(3, Some(&m.tex));
+ }
+ // The extra cameras' textures, held to the end of `compose_frame` like `webcam_tex`.
+ let mut extra_textures = Vec::new();
+ if g.camera_layers.is_empty() {
+ // Without layout regions: camera 0 from `w_dst`, as before the layers.
+ if let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) {
+ // Mirror and the desk-shot turn are both bound swaps: u for horizontal, v for
+ // vertical.
+ let src = crate::frame_geometry::camera_source_rect(
+ Some(&cam0),
+ [wcw, wch],
+ [wtw as f32, wth as f32],
+ g.w_px[0] / g.w_px[1].max(0.0001),
);
+ if cfg.shadow && !webcam_is_block && !is_cutout && g.shape_fade > 0.0 {
+ self.draw_shadow(
+ enc,
+ g.w_dst,
+ g.w_px,
+ g.w_radius,
+ WEBCAM_SHADOW_SPREAD_FRAC * g.frame_min_px,
+ [0.0, WEBCAM_SHADOW_OFFSET_FRAC * g.frame_min_px],
+ WEBCAM_SHADOW_OPACITY * g.shape_fade,
+ );
+ }
+ if let Some(bg) = custom_bg {
+ self.draw_webcam_bg(enc, bg, g.w_dst, g.w_px, g.w_radius, 0.0);
+ }
+ let cb = g.webcam_video_cb(src, w_valid, effect_code, blur_intensity);
+ let cb = crate::frame_geometry::with_camera_homography(cb, Some(&cam0));
+ self.draw_video(enc, &cb, wy, wuv);
}
-
- // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra
- // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement
- // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de
- // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et
- // images y tombaient sur du noir, et le défaut EST une image. L'ordre est imposé :
- // ombre, puis fond, puis caméra.
- let (effect_code, blur_intensity) = match effect {
- Some((code, e)) if code > 2.5 => {
- self.draw_webcam_bg(enc, e.background.as_ref(), g.w_dst, g.w_px, g.w_radius);
- (1.0, 0.0)
+ } else {
+ // Every planned layer, in order: shadow, then (camera 0) the custom background,
+ // then the video. Camera 0 keeps its frame, effects and mask; the extra cameras
+ // draw from `set_extra_camera_frames` without effects, and a missing one is skipped.
+ let render = [rw, rh];
+ let cam0_base = g.webcam_video_cb([0.0; 4], w_valid, effect_code, blur_intensity);
+ for plan in &g.camera_layers {
+ if plan.camera == 0 {
+ let (true, Some((wy, wuv))) = (lp.has_webcam, webcam_tex.as_ref()) else {
+ continue;
+ };
+ let video = crate::frame_geometry::camera_layer_cb(
+ plan,
+ Some(&cam0),
+ [wcw, wch],
+ [wtw as f32, wth as f32],
+ render,
+ &cam0_base,
+ );
+ if let Some(shadow) =
+ g.camera_layer_shadow(plan, &video, render, cfg.shadow, is_cutout)
+ {
+ self.draw_solid(enc, &shadow);
+ }
+ if let Some(bg) = custom_bg {
+ let (dst, quad_px, radius_px) = (video.dst, video.quad_px, video.radius_px);
+ self.draw_webcam_bg(enc, bg, dst, quad_px, radius_px, video.layer_fx[0]);
+ }
+ self.draw_video(enc, &video, wy, wuv);
+ } else {
+ // Tolerant like camera 0: no frame, or no pixel buffer in it, skips the layer.
+ let frame = self.extra_camera_frame(plan.camera);
+ let Ok((ey, euv)) = self.nv12_srvs(frame) else { continue };
+ let (tw, th) = self.tex_dims(frame);
+ let visible = [(*frame).width as f32, (*frame).height as f32];
+ let tex = [tw as f32, th as f32];
+ let valid = [visible[0] / tex[0].max(1.0), visible[1] / tex[1].max(1.0)];
+ let video = crate::frame_geometry::camera_layer_cb(
+ plan,
+ scene_ref.as_ref().and_then(|s| s.camera(plan.camera)),
+ visible,
+ tex,
+ render,
+ &crate::frame_geometry::extra_camera_base_cb(valid),
+ );
+ if let Some(shadow) =
+ g.camera_layer_shadow(plan, &video, render, cfg.shadow, false)
+ {
+ self.draw_solid(enc, &shadow);
+ }
+ self.draw_video(enc, &video, &ey, &euv);
+ extra_textures.push((ey, euv));
}
- Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)),
- None => (0.0, 0.0),
- };
-
- // Metal tolère l'index 3 non lié tant que `fx.z` reste à 0 : la branche n'est
- // pas prise, la texture n'est pas échantillonnée. Dès qu'il monte, elle doit
- // l'être sur TOUT draw capable de la prendre — ici il n'y en a qu'un. L'état
- // d'un encodeur est rémanent, donc lier avant le draw suffit, et l'ombre puis le
- // fond qui précèdent sont en modes 1/2/5/6, que `ps_main` garde hors de la branche
- // (`mode < 0.5`).
- //
- // Pas de déliaison après coup, contrairement au chemin Windows qui remet le slot
- // t3 à `None` : cet état meurt avec l'encodeur, et les annotations en ouvrent un
- // autre. Il n'y a rien sur quoi fuir.
- if let Some(m) = mask.as_ref() {
- enc.set_fragment_texture(3, Some(&m.tex));
}
- self.draw_video(
- enc,
- &LayerCB {
- dst: g.w_dst,
- src: [u0, cv0, u1, cv1],
- quad_px: g.w_px,
- radius_px: g.w_radius,
- mode: 0.0,
- // `color.a` porte l'alpha du découpage (`color.a * personne`) ; le RGB n'est
- // plus lu, le fond ayant déjà été peint sous la caméra.
- color: [0.0, 0.0, 0.0, 1.0],
- fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
- src_prev: [u0, cv0, u1, cv1],
- dst_prev: g.w_dst_prev,
- mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
- ..Default::default()
- },
- wy,
- wuv,
- );
}
+ drop(mask);
enc.end_encoding();
// --- annotations : calque le plus haut, ancré sur le rect ÉCRAN SANS ZOOM ---
// `s_ann`, pas `s_dst` : le zoom vit dans la boîte depuis l'issue #179, donc `s_dst`
// grandit avec lui et emmenait annotations et sous-titres dans le mouvement. Le flou de
- // confidentialité est l'exception : il suit le contenu, d'où `&g`.
- self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g)?;
+ // confidentialité est l'exception : il suit le contenu, d'où `&g` ; il est passé avant le
+ // curseur (plus haut).
+ self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g, false)?;
// Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la
// preview), et l'export ne lit jamais le RGBA — le blit pleine résolution était payé
@@ -4267,6 +4537,12 @@ mod tests {
&metal::CompileOptions::new(),
)
.expect("shaders.metal doit compiler");
+ // La variante des modèles 3D (`LAYER_MODELS`) : la seule à compiler leurs branches.
+ device
+ .new_library_with_source(&super::msl_source_with_models(), &metal::CompileOptions::new())
+ .expect("shaders.metal doit compiler avec LAYER_MODELS")
+ .get_function("ps_main", None)
+ .expect("ps_main absent de la variante LAYER_MODELS");
for name in [
"vs_main",
"vs_fs",
diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs
index 59431e383..74c60b07f 100644
--- a/crates/compositor/src/compositor_windows.rs
+++ b/crates/compositor/src/compositor_windows.rs
@@ -24,8 +24,9 @@ use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::ffi::c_void;
use windows::core::Interface;
+use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Direct3D::{
- D3D11_SRV_DIMENSION_TEXTURE2DARRAY, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP,
+ D3D11_SRV_DIMENSION_TEXTURE2D, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP,
};
use windows::Win32::Graphics::Direct3D11::*;
use windows::Win32::Graphics::Dxgi::Common::*;
@@ -78,6 +79,12 @@ struct WebcamMask {
/// demi-résolution de la texture DÉCODEUR, avec sa chaîne de mips. Dimensionnée sur la texture
/// décodeur et remplie en UV plein (0..1) : ses UV sont ceux de t0/t1, et le calcul d'UV du
/// mode 8 ne change pas.
+/// Le fond tel que la passe de fond (et son flou) l'a laissé dans le RT, et la clé qui le décrit.
+struct BgCache {
+ key: crate::frame_geometry::BackgroundKey,
+ tex: ID3D11Texture2D,
+}
+
struct DofPyramid {
rtv: ID3D11RenderTargetView,
srv: ID3D11ShaderResourceView,
@@ -85,6 +92,15 @@ struct DofPyramid {
height: u32,
}
+/// An extra camera's frame for this `compose_frame`: its private copy's plane views, and the
+/// visible and texture sizes `camera_layer_cb` crops against.
+struct ExtraCamera {
+ y: ID3D11ShaderResourceView,
+ uv: ID3D11ShaderResourceView,
+ visible: [f32; 2],
+ tex: [f32; 2],
+}
+
pub struct Compositor {
dev: ID3D11Device,
ctx: ID3D11DeviceContext,
@@ -93,7 +109,10 @@ pub struct Compositor {
rt_srv: ID3D11ShaderResourceView,
staging: ID3D11Texture2D,
vs: ID3D11VertexShader,
+ /// Le pixel shader des calques, SANS les modèles 3D (modes 15 à 17) : ils passent par
+ /// `ps_models`, et `draw_layer` choisit d'après le mode (le haut de `shaders.hlsl`).
ps: ID3D11PixelShader,
+ ps_models: ID3D11PixelShader,
vs_fs: ID3D11VertexShader,
ps_y: ID3D11PixelShader,
ps_uv: ID3D11PixelShader,
@@ -129,11 +148,16 @@ pub struct Compositor {
accum_rtv: ID3D11RenderTargetView,
accum_srv: ID3D11ShaderResourceView,
blend_add: ID3D11BlendState,
+ /// Rastérisation avec scissor, pour la seule recopie de la traînée du curseur.
+ rs_scissor: ID3D11RasterizerState,
/// Rendu isolé de l'écran cadré (ombre, cadre, métrage, appareil), transparent autour, que le
/// mode 18 recompose le long de sa trajectoire (`FrameGeometry::screen_trail`). Distinct de
/// `accum`, que le curseur et `compose_frame_mb` remplissent dans la même frame.
trail_rtv: ID3D11RenderTargetView,
trail_srv: ID3D11ShaderResourceView,
+ /// Le dernier fond fixe composé et sa clé (`BackgroundKey`) : recopié dans le RT tant
+ /// que la clé tient, au lieu d'être redessiné.
+ bg_cache: RefCell>,
/// RefCell (pas un simple champ) pour que `set_cursor` reste `&self`, comme `set_scene` /
/// `set_live_params` — nécessaire pour le rebrancher par clip dans l'export multiclip, qui
/// n'a qu'une référence partagée au `Compositor`.
@@ -142,6 +166,8 @@ pub struct Compositor {
/// (`frame / FPS`). L'export multiclip et le live le positionnent au PTS écran courant,
/// c'est-à-dire au temps source absolu du clip actif.
cursor_t_override: RefCell >,
+ /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`).
+ footage: std::cell::Cell >,
/// Override du temps des zoom/full-camera regions (secondes source du clip actif). Le nom
/// `timeline_t_override` est conservé pour l'API existante, mais ce temps n'est plus cumulé
/// entre clips : les régions projetées par l'app portent elles aussi des temps source.
@@ -149,9 +175,14 @@ pub struct Compositor {
timeline_t_override: RefCell >,
/// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`.
programme_time: RefCell >,
- // cache des SRV décodeur par (texture array, slice) : le pool réutilise ~32 textures,
- // donc après warmup plus aucune création de SRV par frame (overhead CPU supprimé).
- srv_cache: RefCell>,
+ /// A private copy of a decoder surface, with its two plane views (Y, UV).
+ /// Keyed by the decoder texture's pointer, which pins nothing: `nv12_srvs` re-checks
+ /// the copy's size and format on every hit, so a new texture landing on an old address
+ /// is never handed a copy sized for the old one. `clear_srv_cache` frees stale entries.
+ srv_cache: RefCell>,
+ /// The frames of cameras 1..=3 (`set_extra_camera_frames`), as addresses (0 = none) so
+ /// the compositor's auto traits stay what they were without the raw pointers.
+ extra_camera_frames: Cell<[usize; crate::camera_layers::MAX_EXTRA_CAMERAS]>,
live_params: RefCell,
/// Scène pilotée par l'app (contrat) : quand présente, remplace le layout fixture de
/// `timeline()`. Voir `scene.rs` / `SceneDescription` (TS).
@@ -404,6 +435,7 @@ impl Compositor {
render_size: Cell::new((w, h)),
// Caches dimensionnés à l'ancienne taille : recréés à la demande.
resize_target: RefCell::new(None),
+ bg_cache: RefCell::new(None),
live_readback_staging: RefCell::new(None),
nv12_readback_staging: RefCell::new(None),
..self
@@ -600,6 +632,8 @@ impl Compositor {
dev.CreateVertexShader(shader!("vs_main"), None, Some(&mut vs))?;
let mut ps: Option = None;
dev.CreatePixelShader(shader!("ps_main"), None, Some(&mut ps))?;
+ let mut ps_models: Option = None;
+ dev.CreatePixelShader(shader!("ps_main_models"), None, Some(&mut ps_models))?;
// shaders RGB->NV12
let mut vs_fs: Option = None;
@@ -680,6 +714,17 @@ impl Compositor {
};
let mut blend_add: Option = None;
dev.CreateBlendState(&bla, Some(&mut blend_add))?;
+ // L'état par défaut (aucun lié), plus le scissor. Sans culling : le triangle plein écran
+ // n'a pas à dépendre de son sens de parcours.
+ let rsd = D3D11_RASTERIZER_DESC {
+ FillMode: D3D11_FILL_SOLID,
+ CullMode: D3D11_CULL_NONE,
+ ScissorEnable: true.into(),
+ DepthClipEnable: true.into(),
+ ..Default::default()
+ };
+ let mut rs_scissor: Option = None;
+ dev.CreateRasterizerState(&rsd, Some(&mut rs_scissor))?;
Ok(Compositor {
dev,
@@ -690,6 +735,7 @@ impl Compositor {
staging,
vs: vs.unwrap(),
ps: ps.unwrap(),
+ ps_models: ps_models.unwrap(),
vs_fs: vs_fs.unwrap(),
ps_y: ps_y.unwrap(),
ps_uv: ps_uv.unwrap(),
@@ -718,13 +764,17 @@ impl Compositor {
accum_rtv,
accum_srv,
blend_add: blend_add.unwrap(),
+ rs_scissor: rs_scissor.unwrap(),
trail_rtv,
trail_srv,
+ bg_cache: RefCell::new(None),
cursor: RefCell::new(None),
cursor_t_override: RefCell::new(None),
+ footage: std::cell::Cell::new(None),
timeline_t_override: RefCell::new(None),
programme_time: RefCell::new(None),
srv_cache: RefCell::new(HashMap::new()),
+ extra_camera_frames: Cell::new([0; crate::camera_layers::MAX_EXTRA_CAMERAS]),
live_params: RefCell::new(LiveParams::default()),
scene: RefCell::new(None),
text_raster: match crate::text::TextRasterizer::new() {
@@ -803,43 +853,150 @@ impl Compositor {
/// depuis le layout preset au lieu du planning fixture.
pub fn set_scene(&self, s: Option) {
*self.scene.borrow_mut() = s;
+ // A new scene can drop the regions that drew an extra camera: forget its frames so a
+ // stale pointer is never drawn before the next `set_extra_camera_frames`.
+ self.extra_camera_frames.set([0; crate::camera_layers::MAX_EXTRA_CAMERAS]);
+ }
+
+ /// Frames for cameras 1..=3 (index 0 = scene camera 1). A null or missing entry means that
+ /// camera is not drawn this frame. The compositor keeps the pointers and reads them in every
+ /// `compose_frame` until the next call, so they must stay valid until then. They go through
+ /// `nv12_srvs` and its cache like camera 0's frame (`clear_srv_cache` clears them alike).
+ pub unsafe fn set_extra_camera_frames(&self, frames: &[*const AVFrame]) {
+ let mut slots = [0usize; crate::camera_layers::MAX_EXTRA_CAMERAS];
+ for (slot, frame) in slots.iter_mut().zip(frames) {
+ *slot = *frame as usize;
+ }
+ self.extra_camera_frames.set(slots);
+ }
+
+ /// The frame set for scene camera `camera` (1..=3), null when there is none.
+ fn extra_camera_frame(&self, camera: usize) -> *const AVFrame {
+ let slots = self.extra_camera_frames.get();
+ camera.checked_sub(1).and_then(|k| slots.get(k).copied()).unwrap_or(0) as *const AVFrame
}
- /// Crée les SRV Y (R8) et UV (R8G8) sur la tranche d'array de la frame décodeur.
+ /// The copies (`nv12_srvs`) of every extra camera `layers` draws, with their visible and
+ /// texture sizes. Tolerant: a camera without a frame, or whose frame has no D3D11 texture,
+ /// is `None` and simply not drawn.
+ unsafe fn extra_camera_srvs(
+ &self,
+ layers: &[crate::camera_layers::CameraLayerPlan],
+ ) -> [Option; crate::camera_layers::MAX_EXTRA_CAMERAS] {
+ let mut out: [Option; crate::camera_layers::MAX_EXTRA_CAMERAS] =
+ Default::default();
+ for (k, slot) in out.iter_mut().enumerate() {
+ let frame = self.extra_camera_frame(k + 1);
+ if frame.is_null() || !layers.iter().any(|l| l.camera == k + 1) {
+ continue;
+ }
+ let Ok((y, uv)) = self.nv12_srvs(frame) else { continue };
+ let (tw, th) = self.tex_dims(frame);
+ *slot = Some(ExtraCamera {
+ y,
+ uv,
+ visible: [(*frame).width as f32, (*frame).height as f32],
+ tex: [tw as f32, th as f32],
+ });
+ }
+ out
+ }
+
+ /// The Y (R8) and UV (R8G8) views of the decoder frame — over a private COPY, never
+ /// over the decoder surface itself.
+ ///
+ /// Two documented D3D11 rules rule out the direct path. The first is explicit, on
+ /// `D3D11_BIND_DECODER`: "you cannot use texture arrays that are created with this
+ /// flag in calls to ID3D11Device::CreateShaderResourceView". The ffmpeg pool IS such
+ /// an array (`initial_pool_size` = 32, see `get_hw_format`). The second explains what
+ /// we were seeing: between the video engine and the 3D pipeline "there is no automatic
+ /// hazard tracking" — the surface stays the decoder's reference frame, so it may be
+ /// rewritten WHILE the shader samples it. ffmpeg's `ID3D11VideoContext` is in fact the
+ /// SAME object as our immediate context (it comes out of a QueryInterface on it), and
+ /// `SetMultithreadProtected(TRUE)` only makes an individual call atomic, never a
+ /// sequence.
+ ///
+ /// Measured on a Snapdragon X Elite (Adreno X1-85), same build, same recording, the
+ /// path picked by an environment variable: 768 black frames out of 792 when sampling
+ /// the decoder surface, 2 out of 545 through the copy. The black was OPAQUE and total,
+ /// so not "one frame late" but no frame at all.
+ ///
+ /// `CopySubresourceRegion` is issued on the immediate context, so it IS ordered against
+ /// the draws that follow; the destination texture is `ArraySize = 1` and carries only
+ /// `BIND_SHADER_RESOURCE`, which also takes it out of the restriction above. The cost
+ /// is one GPU→GPU copy per frame and per source, with nothing going back to system
+ /// memory.
+ ///
+ /// What hid the cause for so long: exporting the SAME scene never produced a black
+ /// frame (28 342 verified), because the export drains the GPU every frame through the
+ /// encoder. Everything that slowed the live loop — one more readback, a lock, a pause —
+ /// cut the black proportionally without ever removing it.
pub unsafe fn nv12_srvs(
&self,
frame: *const AVFrame,
) -> Result<(ID3D11ShaderResourceView, ID3D11ShaderResourceView)> {
let tex_ptr = (*frame).data[0] as *mut c_void;
let slice = (*frame).data[1] as u32;
- // cache hit : le pool réutilise les mêmes textures -> zéro création après warmup
- let key = (tex_ptr as usize, slice);
- if let Some((y, uv)) = self.srv_cache.borrow().get(&key) {
- return Ok((y.clone(), uv.clone()));
- }
- let tex = ID3D11Texture2D::from_raw_borrowed(&tex_ptr)
- .ok_or_else(|| anyhow::anyhow!("frame sans texture D3D11"))?
+ let src = ID3D11Texture2D::from_raw_borrowed(&tex_ptr)
+ .ok_or_else(|| anyhow::anyhow!("frame has no D3D11 texture"))?
.clone();
- let mk = |fmt: DXGI_FORMAT| -> Result {
- let mut d = D3D11_SHADER_RESOURCE_VIEW_DESC {
- Format: fmt,
- ViewDimension: D3D11_SRV_DIMENSION_TEXTURE2DARRAY,
- ..Default::default()
- };
- d.Anonymous.Texture2DArray = D3D11_TEX2D_ARRAY_SRV {
- MostDetailedMip: 0,
- MipLevels: 1,
- FirstArraySlice: slice,
- ArraySize: 1,
- };
- let mut srv: Option = None;
- self.dev.CreateShaderResourceView(&tex, Some(&d), Some(&mut srv))?;
- Ok(srv.unwrap())
+ // Cache hit: the pool reuses the same textures, so this allocates once per decoder
+ // after warmup. The COPY itself happens on EVERY frame — that is what freezes the
+ // slice's contents before the decoder takes it back.
+ //
+ // The key is a bare address and pins nothing. A decoder replaced without
+ // `clear_srv_cache` (a mid-stream resolution change, `CpuFrames::ensure_tex`) can
+ // hand a NEW texture the address of an old one, and a copy into a smaller destination
+ // is dropped without an error: the picture freezes. So a hit only counts while the
+ // destination still has the source's size and format.
+ let mut sd = D3D11_TEXTURE2D_DESC::default();
+ src.GetDesc(&mut sd);
+ let key = tex_ptr as usize;
+ let cached = self.srv_cache.borrow().get(&key).cloned().filter(|(dst, ..)| {
+ let mut dd = D3D11_TEXTURE2D_DESC::default();
+ dst.GetDesc(&mut dd);
+ (dd.Width, dd.Height, dd.Format) == (sd.Width, sd.Height, sd.Format)
+ });
+ let (dst, y, uv) = match cached {
+ Some(v) => v,
+ None => {
+ let dd = D3D11_TEXTURE2D_DESC {
+ Width: sd.Width,
+ Height: sd.Height,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: sd.Format,
+ SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
+ Usage: D3D11_USAGE_DEFAULT,
+ BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
+ CPUAccessFlags: 0,
+ MiscFlags: 0,
+ };
+ let mut dst: Option = None;
+ self.dev.CreateTexture2D(&dd, None, Some(&mut dst))?;
+ let dst = dst.unwrap();
+ let mk = |fmt: DXGI_FORMAT| -> Result {
+ let mut d = D3D11_SHADER_RESOURCE_VIEW_DESC {
+ Format: fmt,
+ ViewDimension: D3D11_SRV_DIMENSION_TEXTURE2D,
+ ..Default::default()
+ };
+ d.Anonymous.Texture2D = D3D11_TEX2D_SRV { MostDetailedMip: 0, MipLevels: 1 };
+ let mut srv: Option = None;
+ self.dev.CreateShaderResourceView(&dst, Some(&d), Some(&mut srv))?;
+ Ok(srv.unwrap())
+ };
+ let y = mk(DXGI_FORMAT_R8_UNORM)?;
+ let uv = mk(DXGI_FORMAT_R8G8_UNORM)?;
+ self.srv_cache
+ .borrow_mut()
+ .insert(key, (dst.clone(), y.clone(), uv.clone()));
+ (dst, y, uv)
+ }
};
- let y = mk(DXGI_FORMAT_R8_UNORM)?;
- let uv = mk(DXGI_FORMAT_R8G8_UNORM)?;
- self.srv_cache.borrow_mut().insert(key, (y.clone(), uv.clone()));
+
+ self.ctx.CopySubresourceRegion(&dst, 0, 0, 0, 0, &src, slice, None);
Ok((y, uv))
}
@@ -874,6 +1031,26 @@ impl Compositor {
self.ctx.ClearRenderTargetView(&self.rtv, &clear);
}
+ /// Garde le fond que le RT porte à cet instant (`bg_cache`), sous `key`.
+ unsafe fn keep_background(&self, key: crate::frame_geometry::BackgroundKey) -> Result<()> {
+ // L'ancienne texture est reprise : `resized` vide le cache, sa taille est la bonne.
+ let old = self.bg_cache.borrow_mut().take().map(|c| c.tex);
+ let tex = match old {
+ Some(tex) => tex,
+ None => {
+ let mut d = D3D11_TEXTURE2D_DESC::default();
+ self.rt.GetDesc(&mut d);
+ d.BindFlags = 0;
+ let mut tex: Option = None;
+ self.dev.CreateTexture2D(&d, None, Some(&mut tex))?;
+ tex.ok_or_else(|| anyhow::anyhow!("CreateTexture2D (fond gardé)"))?
+ }
+ };
+ self.ctx.CopyResource(&tex, &self.rt);
+ *self.bg_cache.borrow_mut() = Some(BgCache { key, tex });
+ Ok(())
+ }
+
/// Passe plein écran générique (triangle unique) : `srv` -> `rtv` via `ps`, avec `fx`.
unsafe fn fs_pass(
&self,
@@ -1025,6 +1202,15 @@ impl Compositor {
self.ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
}
+ /// Dessine le quad d'un calque, ses ressources déjà liées. Le pixel shader suit son mode :
+ /// `ps_models` pour un modèle 3D (`LayerCB::needs_models`), `ps` pour tout le reste, qui ne
+ /// paie pas ainsi les registres de ces modèles.
+ unsafe fn draw_layer(&self, cb: &LayerCB) {
+ self.upload_cb(cb);
+ self.ctx.PSSetShader(if cb.needs_models() { &self.ps_models } else { &self.ps }, None);
+ self.ctx.Draw(4, 0);
+ }
+
/// Calque vidéo NV12.
pub unsafe fn draw_video(
&self,
@@ -1032,16 +1218,14 @@ impl Compositor {
srv_y: &ID3D11ShaderResourceView,
srv_uv: &ID3D11ShaderResourceView,
) {
- self.upload_cb(cb);
self.ctx
.PSSetShaderResources(0, Some(&[Some(srv_y.clone()), Some(srv_uv.clone())]));
- self.ctx.Draw(4, 0);
+ self.draw_layer(cb);
}
/// Calque couleur pleine (fond).
pub unsafe fn draw_solid(&self, cb: &LayerCB) {
- self.upload_cb(cb);
- self.ctx.Draw(4, 0);
+ self.draw_layer(cb);
}
/// Fond wallpaper image (cover-fit). `path` = chemin absolu (résolu côté app). Décodé et
@@ -1103,13 +1287,14 @@ impl Compositor {
programme_t: f32,
) -> Result<()> {
let full = [0.0, 0.0, 1.0, 1.0];
- self.draw_image_in(path, full, [0.0, 0.0], 0.0, output_aspect, motion, programme_t)
+ self.draw_image_in(path, full, [0.0, 0.0], 0.0, output_aspect, motion, programme_t, 0.0)
}
/// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins
/// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop
/// « cover » se calcule contre la zone qu'on remplit. `motion` anime l'image au temps
- /// programme `programme_t` ; la bulle passe `WallpaperMotion::None`.
+ /// programme `programme_t` ; la bulle passe `WallpaperMotion::None`. `transparency` =
+ /// `layer_fx.x` (0 = opaque): a camera layer's background box fades with the layer.
#[allow(clippy::too_many_arguments)]
unsafe fn draw_image_in(
&self,
@@ -1120,6 +1305,7 @@ impl Compositor {
output_aspect: f32,
motion: WallpaperMotion,
programme_t: f32,
+ transparency: f32,
) -> Result<()> {
let (srv, iw, ih) = self.cached_image(path)?;
let ai = iw as f32 / ih as f32;
@@ -1135,7 +1321,8 @@ impl Compositor {
(0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5)
};
let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots(motion, programme_t, ao);
- self.upload_cb(&LayerCB {
+ self.ctx.PSSetShaderResources(2, Some(&[Some(srv)]));
+ self.draw_layer(&LayerCB {
dst,
src: [u0, v0, u1, v1],
quad_px,
@@ -1143,10 +1330,9 @@ impl Compositor {
mode: 6.0,
fx: [0.0, 0.0, anim[0], anim[1]],
mb,
+ layer_fx: [transparency, 0.0, 0.0, 0.0],
..Default::default()
});
- self.ctx.PSSetShaderResources(2, Some(&[Some(srv)]));
- self.ctx.Draw(4, 0);
Ok(())
}
@@ -1162,20 +1348,25 @@ impl Compositor {
///
/// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit épouser ses coins arrondis,
/// sinon un rectangle déborde derrière la caméra.
+ ///
+ /// `transparency` = the camera layer's `layer_fx.x`: the box fades with the camera.
unsafe fn draw_webcam_bg(
&self,
bg: Option<&SceneBackground>,
dst: [f32; 4],
quad_px: [f32; 2],
radius_px: f32,
+ transparency: f32,
) {
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
+ let layer_fx = [transparency, 0.0, 0.0, 0.0];
let solid = |color: [f32; 4]| LayerCB {
dst,
quad_px,
radius_px,
mode: 1.0,
color,
+ layer_fx,
..Default::default()
};
match bg {
@@ -1192,6 +1383,7 @@ impl Compositor {
quad_px,
radius_px,
fx: [dir[0], dir[1], 0.0, 0.0],
+ layer_fx,
..crate::frame_geometry::gradient_layer(stops, offsets, BLACK)
});
}
@@ -1201,7 +1393,9 @@ impl Compositor {
let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 };
let still = WallpaperMotion::None;
if let Err(e) =
- self.draw_image_in(path, dst, quad_px, radius_px, aspect, still, 0.0)
+ self.draw_image_in(
+ path, dst, quad_px, radius_px, aspect, still, 0.0, transparency,
+ )
{
eprintln!("[compositor] fond webcam \"{}\" : {:#}", path, e);
self.draw_solid(&solid(BLACK));
@@ -1661,7 +1855,7 @@ impl Compositor {
/// Curseur custom (dot+ring) centré en `center` (0..1 sortie), taille `size_px`, opacité `a`.
/// `clip` = rect "Clip to canvas" en espace sortie [x,y,w,h] ; passer un rect englobant tout
/// (ex. [-1,-1,3,3]) pour désactiver l'effet.
- unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) {
+ unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) -> [f32; 4] {
let w = size_px / self.rw();
let h = size_px / self.rh();
let dst = [center[0] - w * 0.5, center[1] - h * 0.5, w, h];
@@ -1673,6 +1867,7 @@ impl Compositor {
fx: clip,
..Default::default()
});
+ dst
}
/// Curseur thème (sprite PNG, ex. arrow.png) dont le PIVOT `hotspot` (fraction 0..1 de
@@ -1690,7 +1885,7 @@ impl Compositor {
sprite: &SceneCursorSprite,
clip: [f32; 4],
model: Option,
- ) -> Result<()> {
+ ) -> Result> {
let path = sprite.path.as_str();
let (srv, iw, ih) = self.cached_image(path)?;
// Sans champ de distance, repli sur le sprite plat plutôt que sur le curseur math.
@@ -1699,16 +1894,16 @@ impl Compositor {
match self.cursor_sdf(path) {
Ok((sdf, shape)) => {
let shape = crate::frame_geometry::model_shape(sprite, shape);
- if let Some(cb) = crate::frame_geometry::cursor_model_cb(
+ let Some(cb) = crate::frame_geometry::cursor_model_cb(
placement, size_px, pose, shape, a, clip,
- ) {
- self.upload_cb(&cb);
- self.ctx.PSSetShaderResources(2, Some(&[Some(srv)]));
- self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)]));
- self.ctx.Draw(4, 0);
- self.ctx.PSSetShaderResources(4, Some(&[None]));
- }
- return Ok(());
+ ) else {
+ return Ok(None);
+ };
+ self.ctx.PSSetShaderResources(2, Some(&[Some(srv)]));
+ self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)]));
+ self.draw_layer(&cb);
+ self.ctx.PSSetShaderResources(4, Some(&[None]));
+ return Ok(Some(cb.dst));
}
Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"),
}
@@ -1723,10 +1918,9 @@ impl Compositor {
clip,
[self.rw(), self.rh()],
);
- self.upload_cb(&cb);
self.ctx.PSSetShaderResources(2, Some(&[Some(srv)]));
- self.ctx.Draw(4, 0);
- Ok(())
+ self.draw_layer(&cb);
+ Ok(Some(cb.dst))
}
/// Sprite de l'état courant (`cursor_type`, ex. `"text"`), à défaut celui de la flèche,
@@ -1746,17 +1940,17 @@ impl Compositor {
a: f32,
clip: [f32; 4],
model: Option,
- ) {
+ ) -> Option<[f32; 4]> {
let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow"));
if let Some(sprite) = sprite {
- if self.draw_cursor_sprite(placement, size_px, a, sprite, clip, model).is_ok() {
- return;
+ if let Ok(dst) = self.draw_cursor_sprite(placement, size_px, a, sprite, clip, model) {
+ return dst;
}
}
// Le repli math reste droit même sur un plan incliné : il ne devrait plus apparaître
// maintenant que l'art par défaut existe, et lui donner sa propre passe de warp pour
// un cas de secours ne se justifie pas.
- self.draw_cursor(placement.upright_center(), size_px, a, clip);
+ Some(self.draw_cursor(placement.upright_center(), size_px, a, clip))
}
/// Ombre portée (§7 E4) sous un quad `dst` (normalisé) de taille `size_px`.
@@ -1850,6 +2044,11 @@ impl Compositor {
});
}
+ /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`).
+ pub fn footage_quad(&self) -> Option {
+ self.footage.get()
+ }
+
/// Compose une frame animée (§6/§8) : fond flouté + screen zoomé (padding, coins, ombre)
/// + webcam crop carré (coins, ombre), placements interpolés A↔B par la timeline.
pub unsafe fn compose_frame(
@@ -1893,8 +2092,10 @@ impl Compositor {
timeline_t_override: *self.timeline_t_override.borrow(),
programme_time: *self.programme_time.borrow(),
});
+ self.footage.set(Some(g.footage_quad([self.rw(), self.rh()])));
+ // The extra cameras' copies, made before anything is drawn like the two above.
+ let extra_cams = self.extra_camera_srvs(&g.camera_layers);
let scene_preset = g.scene_preset.clone();
- let mb_taps = g.mb_taps;
let mb_amount = g.mb_amount;
let source_t = g.source_t;
let _padding_scale = g.padding_scale;
@@ -1905,7 +2106,6 @@ impl Compositor {
let s_radius = g.s_radius;
let frame_min_px = g.frame_min_px;
let w_dst = g.w_dst;
- let w_dst_prev = g.w_dst_prev;
let w_px = g.w_px;
let w_radius = g.w_radius;
let shape_fade = g.shape_fade;
@@ -1915,8 +2115,6 @@ impl Compositor {
let dof = g.depth_of_field_on(self.cpu_backend);
let dof_srv = if dof { Some(self.fill_dof_pyramid(&sy, &suv, stw, sth)?) } else { None };
- self.begin([0.0, 0.0, 0.0, 1.0]);
-
// --- fond ---
// Parité web (frameRenderer.blurredBackgroundLayer) : le fond est le WALLPAPER sélectionné
// (image/couleur/gradient) et « Blur BG » floute CE wallpaper, PAS la vidéo. Le natif
@@ -1925,86 +2123,120 @@ impl Compositor {
// ensuite ; pour une couleur plate le flou est un no-op visuel). Côté fixture/bench
// (pas de scène) on garde le fond screen-flouté, dont le coût est mesuré (C4).
let scene_bg = self.scene.borrow().as_ref().map(|s| (s.background.clone(), s.effects.blur));
- if let Some((bg, blur_wallpaper)) = scene_bg {
- match bg {
- SceneBackground::Color { color } => {
- let c = parse_hex(&color).unwrap_or(lp.bg_color);
- self.draw_solid(&LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
- mode: 1.0,
- color: c,
- ..Default::default()
- });
- }
- SceneBackground::Gradient { angle_deg, stops, offsets, motion } => {
- // angle CSS → direction unitaire (espace sortie, y vers le bas) :
- // 0° = vers le haut, 90° = vers la droite.
- let a = angle_deg.to_radians();
- let dir = [a.sin(), -a.cos()];
- let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots(
- motion,
- g.programme_t,
- self.rw() / self.rh(),
- );
- self.draw_solid(&LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
- fx: [dir[0], dir[1], anim[0], anim[1]],
- mb,
- ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color)
- });
- }
- SceneBackground::Image { path, motion } => {
- // image bg (cover-fit, mise en cache) ; fallback couleur si chargement échoue
- // (loggé — un fallback silencieux masquerait un chemin cassé, cf. le panic
- // borrow qu'on a déjà eu : toute panne doit être visible/traçable).
- let aspect = self.rw() / self.rh();
- if let Err(e) = self.draw_image_bg(&path, aspect, motion, g.programme_t) {
- eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e);
+ // Le fond de la frame d'avant, s'il est celui qu'on demande (`BackgroundKey`), est recopié
+ // au lieu d'être redessiné. Côté scène seulement : sans scène, le fond est la vidéo floutée.
+ let bg_size = [self.rw(), self.rh()];
+ let bg_cached = scene_ref.as_ref().is_some_and(|s| {
+ self.bg_cache
+ .borrow()
+ .as_ref()
+ .is_some_and(|c| {
+ c.key.matches(Some(&s.background), s.effects.blur, lp.bg_color, bg_size)
+ })
+ });
+ if bg_cached {
+ self.bind_compose_state();
+ if let Some(c) = self.bg_cache.borrow().as_ref() {
+ self.ctx.CopyResource(&self.rt, &c.tex);
+ }
+ } else {
+ self.begin([0.0, 0.0, 0.0, 1.0]);
+ // Faux si l'image du fond n'a pas pu se charger : son repli n'est pas gardé.
+ let mut bg_drawn = true;
+ if let Some((bg, blur_wallpaper)) = scene_bg {
+ match bg {
+ SceneBackground::Color { color } => {
+ let c = parse_hex(&color).unwrap_or(lp.bg_color);
self.draw_solid(&LayerCB {
dst: [0.0, 0.0, 1.0, 1.0],
mode: 1.0,
- color: lp.bg_color,
+ color: c,
..Default::default()
});
}
+ SceneBackground::Gradient { angle_deg, stops, offsets, motion } => {
+ // angle CSS → direction unitaire (espace sortie, y vers le bas) :
+ // 0° = vers le haut, 90° = vers la droite.
+ let a = angle_deg.to_radians();
+ let dir = [a.sin(), -a.cos()];
+ let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots(
+ motion,
+ g.programme_t,
+ self.rw() / self.rh(),
+ );
+ self.draw_solid(&LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ fx: [dir[0], dir[1], anim[0], anim[1]],
+ mb,
+ ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color)
+ });
+ }
+ SceneBackground::Image { path, motion } => {
+ // image bg (cover-fit, mise en cache) ; fallback couleur si chargement échoue
+ // (loggé — un fallback silencieux masquerait un chemin cassé, cf. le panic
+ // borrow qu'on a déjà eu : toute panne doit être visible/traçable).
+ let aspect = self.rw() / self.rh();
+ if let Err(e) = self.draw_image_bg(&path, aspect, motion, g.programme_t) {
+ eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e);
+ bg_drawn = false;
+ self.draw_solid(&LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ mode: 1.0,
+ color: lp.bg_color,
+ ..Default::default()
+ });
+ }
+ }
}
- }
- // « Blur BG » (parité web blurredBackgroundLayer) : floute CE wallpaper qu'on vient
- // de dessiner (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op
- // visuel sur une couleur plate, effet réel sur gradient/image.
- if blur_wallpaper > 0.0 {
- self.blur_bg(blur_wallpaper);
+ // « Blur BG » (parité web blurredBackgroundLayer) : floute CE wallpaper qu'on vient
+ // de dessiner (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op
+ // visuel sur une couleur plate, effet réel sur gradient/image.
+ if blur_wallpaper > 0.0 {
+ self.blur_bg(blur_wallpaper);
+ self.bind_compose_state();
+ }
+ } else if cfg.bg_blur > 0.0 {
+ let over = 0.06;
+ self.draw_video(
+ &LayerCB {
+ dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over],
+ src: [0.0, 0.0, u_max, v_max],
+ quad_px: [self.rw(), self.rh()],
+ mode: 0.0,
+ color: [1.0, 1.0, 1.0, 1.0],
+ ..Default::default()
+ },
+ &sy,
+ &suv,
+ );
+ self.blur_bg(cfg.bg_blur);
self.bind_compose_state();
- }
- } else if cfg.bg_blur > 0.0 {
- let over = 0.06;
- self.draw_video(
- &LayerCB {
- dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over],
- src: [0.0, 0.0, u_max, v_max],
- quad_px: [self.rw(), self.rh()],
- mode: 0.0,
- color: [1.0, 1.0, 1.0, 1.0],
+ self.draw_solid(&LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ mode: 1.0,
+ color: [0.0, 0.0, 0.0, 0.35],
..Default::default()
- },
- &sy,
- &suv,
- );
- self.blur_bg(cfg.bg_blur);
- self.bind_compose_state();
- self.draw_solid(&LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
- mode: 1.0,
- color: [0.0, 0.0, 0.0, 0.35],
- ..Default::default()
- });
- } else {
- self.draw_solid(&LayerCB {
- dst: [0.0, 0.0, 1.0, 1.0],
- mode: 1.0,
- color: lp.bg_color,
- ..Default::default()
+ });
+ } else {
+ self.draw_solid(&LayerCB {
+ dst: [0.0, 0.0, 1.0, 1.0],
+ mode: 1.0,
+ color: lp.bg_color,
+ ..Default::default()
+ });
+ }
+ // Pas de cache pour un fond animé (`of` rend `None`).
+ let key = scene_ref.as_ref().filter(|_| bg_drawn).and_then(|s| {
+ crate::frame_geometry::BackgroundKey::of(
+ Some(&s.background),
+ s.effects.blur,
+ lp.bg_color,
+ bg_size,
+ )
});
+ if let Some(key) = key {
+ self.keep_background(key)?;
+ }
}
// --- screen : crop du clip actif, puis zoom appliqué dans ce rect source (§8) ---
@@ -2138,6 +2370,11 @@ impl Compositor {
self.ctx.PSSetShaderResources(5, Some(&[None]));
}
+ // --- flous de confidentialité : sur le métrage, AVANT le curseur. Le curseur reste net
+ // par-dessus, et le cristal de Prism Glow, qui réfracte l'image composée, n'y voit que des
+ // pixels déjà floutés. Les autres annotations restent le calque le plus haut (plus bas).
+ self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g, true);
+
// --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce,
// et flou de mouvement (parité `compositor_macos.rs` et `compositor_linux.rs`) ---
if let Some(track) = cursor_ref.as_ref() {
@@ -2161,6 +2398,13 @@ impl Compositor {
.map(|s| s.cursor.cursor_sprites.clone())
.unwrap_or_default();
let cursor_type = plan.cursor_type.as_deref();
+ // Le cristal de Prism Glow réfracte l'image telle qu'elle est composée à cet
+ // instant, flous compris : sa copie en t5, une fois pour toutes les copies de la
+ // traînée (`ann_copy` est libre, les flous l'ont déjà lue).
+ if plan.glass {
+ self.ctx.CopySubresourceRegion(&self.ann_copy, 0, 0, 0, 0, &self.rt, 0, None);
+ self.ctx.PSSetShaderResources(5, Some(&[Some(self.ann_copy_srv.clone())]));
+ }
// L'impact des clics (mode 16, sans texture), posé sur l'écran SOUS le curseur.
for cb in &plan.impacts {
self.draw_solid(cb);
@@ -2180,11 +2424,14 @@ impl Compositor {
// buffer ISOLÉ (transparent), pas directement sur la scène déjà composée.
self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]);
self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None);
+ // L'union des quads peints dans `accum` (x0, y0, x1, y1 en fractions de la
+ // sortie) : la seule région que la recopie doit relire.
+ let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
for k in 0..plan.taps {
let f = k as f32 / (plan.taps - 1) as f32;
let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps);
self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff);
- self.draw_cur_themed(
+ let drawn = self.draw_cur_themed(
&cursor_sprites,
cursor_type,
plan.prev_placement.lerp(plan.placement, f),
@@ -2193,6 +2440,14 @@ impl Compositor {
plan.clip,
plan.model,
);
+ if let Some(d) = drawn {
+ bounds = [
+ bounds[0].min(d[0]),
+ bounds[1].min(d[1]),
+ bounds[2].max(d[0] + d[2]),
+ bounds[3].max(d[1] + d[3]),
+ ];
+ }
}
// composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié).
self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None);
@@ -2210,12 +2465,30 @@ impl Compositor {
};
self.ctx.RSSetViewports(Some(&[vp]));
self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff);
- self.ctx.Draw(3, 0);
+ // Rien n'a été peint dans `accum` hors des quads de la traînée : la recopie
+ // s'y borne au lieu de repasser sur toute la sortie (0,5 ms par frame 1080p
+ // mesurée sur Linux, Radeon 610M).
+ let (rw, rh) = (self.rw(), self.rh());
+ let rect = RECT {
+ left: (bounds[0] * rw).floor().clamp(0.0, rw) as i32,
+ top: (bounds[1] * rh).floor().clamp(0.0, rh) as i32,
+ right: (bounds[2] * rw).ceil().clamp(0.0, rw) as i32,
+ bottom: (bounds[3] * rh).ceil().clamp(0.0, rh) as i32,
+ };
+ if rect.right > rect.left && rect.bottom > rect.top {
+ self.ctx.RSSetState(&self.rs_scissor);
+ self.ctx.RSSetScissorRects(Some(&[rect]));
+ self.ctx.Draw(3, 0);
+ self.ctx.RSSetState(None);
+ }
self.ctx.PSSetShaderResources(0, Some(&[None]));
// restaure l'état de composition standard (VS/PS/topologie quad-strip) pour
// le dessin de la webcam qui suit juste après.
self.bind_compose_state();
}
+ if plan.glass {
+ self.ctx.PSSetShaderResources(5, Some(&[None]));
+ }
}
}
@@ -2233,99 +2506,144 @@ impl Compositor {
//
// Le center-crop carré de square/circle en est un cas particulier (boîte 1:1) — il n'a
// plus besoin d'être traité à part.
- let [su0, sv0, su1, sv1] = crate::frame_geometry::webcam_source_rect(
- [wcw, wch],
- [wtw as f32, wth as f32],
- scene_ref
- .as_ref()
- .and_then(|scene| scene.layout.webcam_crop),
- w_px[0] / w_px[1].max(0.0001),
+ // Camera 0's crop, mirror and desk turn as camera settings, plus its homography
+ // (`Scene::camera(0)`): with one, `src` is not read and the crop is dropped.
+ let cam0 = g.camera0_settings(scene_ref.as_ref());
+ // L'ombre portée appartient à la bulle flottante PiP : elle se retire avec elle
+ // (`shape_fade`), pour qu'au plein écran plus rien n'encadre la caméra. C'est une
+ // ombre légère NON paramétrable — indépendante du slider Shadow, qui ne pilote plus
+ // que l'écran (`WEBCAM_SHADOW_OPACITY`, pas `shadow_scale`) — et propre au PiP : les
+ // blocs side-by-side / top-bottom soudent la caméra à l'écran et n'en portent aucune
+ // (parité `preset.shadow` web, `null` hors PiP dans `compositeLayout.ts`).
+ let webcam_is_block = matches!(
+ scene_preset.as_deref(),
+ Some("dual-frame") | Some("vertical-stack"),
);
- // miroir = échanger les bornes u du rect source (flip horizontal).
- let (u0, u1) = if lp.webcam_mirror { (su1, su0) } else { (su0, su1) };
- if lp.has_webcam {
- // L'ombre portée appartient à la bulle flottante PiP : elle se retire avec elle
- // (`shape_fade`), pour qu'au plein écran plus rien n'encadre la caméra. C'est une
- // ombre légère NON paramétrable — indépendante du slider Shadow, qui ne pilote plus
- // que l'écran (`WEBCAM_SHADOW_OPACITY`, pas `shadow_scale`) — et propre au PiP : les
- // blocs side-by-side / top-bottom soudent la caméra à l'écran et n'en portent aucune
- // (parité `preset.shadow` web, `null` hors PiP dans `compositeLayout.ts`).
- let webcam_is_block = matches!(
- scene_preset.as_deref(),
- Some("dual-frame") | Some("vertical-stack"),
- );
- // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
- // ombre portée par un rectangle invisible se lit comme un artefact.
- let is_cutout = matches!(
- scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()),
- Some(e) if e.shader_code() == 1.0
- ) && self.webcam_mask.borrow().is_some();
- if cfg.shadow && !webcam_is_block && !is_cutout && shape_fade > 0.0 {
- let strength = WEBCAM_SHADOW_OPACITY * shape_fade;
- self.draw_shadow(
- w_dst,
- w_px,
- w_radius,
- WEBCAM_SHADOW_SPREAD_FRAC * frame_min_px,
- [0.0, WEBCAM_SHADOW_OFFSET_FRAC * frame_min_px],
- strength,
- );
- }
- // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
- // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
- // invisible en détourage, donc tant que rien n'a été segmenté on dessine la piste
- // telle quelle. C'est aussi ce qui rend le premier lancement gracieux.
- let mask = self.webcam_mask.borrow();
- let effect = scene_ref
- .as_ref()
- .and_then(|s| s.webcam_effect.as_ref())
- .filter(|_| mask.is_some())
- .map(|e| (e.shader_code(), e))
- .filter(|(code, _)| *code > 0.0);
-
- // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra
- // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement
- // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de
- // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et
- // images y tombaient sur du noir, et le défaut EST une image.
- let (effect_code, blur_intensity) = match effect {
- Some((code, e)) if code > 2.5 => {
- self.draw_webcam_bg(e.background.as_ref(), w_dst, w_px, w_radius);
- (1.0, 0.0)
- }
- Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)),
- None => (0.0, 0.0),
- };
-
+ // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une
+ // ombre portée par un rectangle invisible se lit comme un artefact.
+ let is_cutout = matches!(
+ scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()),
+ Some(e) if e.shader_code() == 1.0
+ ) && self.webcam_mask.borrow().is_some();
+ // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de
+ // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam
+ // invisible en détourage, donc tant que rien n'a été segmenté on dessine la piste
+ // telle quelle. C'est aussi ce qui rend le premier lancement gracieux.
+ let mask = self.webcam_mask.borrow();
+ let effect = scene_ref
+ .as_ref()
+ .and_then(|s| s.webcam_effect.as_ref())
+ .filter(|_| mask.is_some())
+ .map(|e| (e.shader_code(), e))
+ .filter(|(code, _)| *code > 0.0);
+ // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra
+ // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement
+ // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de
+ // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et
+ // images y tombaient sur du noir, et le défaut EST une image.
+ // `custom_bg` = Some(background) in that mode; it is painted between shadow and camera.
+ let (effect_code, blur_intensity, custom_bg) = match effect {
+ Some((code, e)) if code > 2.5 => (1.0, 0.0, Some(e.background.as_ref())),
+ Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), None),
+ None => (0.0, 0.0, None),
+ };
+ // Camera 0's video draw: the mask, bound to slot 3 for this draw only. `draw_video` ne
+ // lie que les slots 0-1, donc le masque posé ici tient pour l'appel qui suit. Il est
+ // délié juste après pour ne pas fuir sur les calques d'annotation, qui utilisent eux
+ // aussi le slot 2 et au-delà.
+ let draw_camera0 = |cb: &LayerCB| {
if let Some(m) = mask.as_ref() {
- // `draw_video` ne lie que les slots 0-1, donc le masque posé ici tient pour
- // l'appel qui suit. Il est délié juste après pour ne pas fuir sur les calques
- // d'annotation, qui utilisent eux aussi le slot 2 et au-delà.
self.ctx.PSSetShaderResources(3, Some(&[Some(m.srv.clone())]));
}
- self.draw_video(
- &LayerCB {
- dst: w_dst,
- src: [u0, sv0, u1, sv1],
- quad_px: w_px,
- radius_px: w_radius,
- mode: 0.0,
- // `color.a` porte l'alpha du découpage (`color.a * personne`) ; le RGB n'est
- // plus lu, le fond ayant déjà été peint sous la caméra.
- color: [0.0, 0.0, 0.0, 1.0],
- fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
- src_prev: [u0, sv0, u1, sv1], // src fixe (pas de zoom webcam)
- dst_prev: w_dst_prev,
- mb: [mb_taps, mb_amount, 1.0, 0.0],
- ..Default::default()
- },
- &wy,
- &wuv,
- );
+ self.draw_video(cb, &wy, &wuv);
if mask.is_some() {
self.ctx.PSSetShaderResources(3, Some(&[None]));
}
+ };
+
+ if g.camera_layers.is_empty() {
+ // Without layout regions: camera 0 from `w_dst`, as before the layers.
+ let [u0, v0, u1, v1] = crate::frame_geometry::camera_source_rect(
+ Some(&cam0),
+ [wcw, wch],
+ [wtw as f32, wth as f32],
+ w_px[0] / w_px[1].max(0.0001),
+ );
+ if lp.has_webcam {
+ if cfg.shadow && !webcam_is_block && !is_cutout && shape_fade > 0.0 {
+ let strength = WEBCAM_SHADOW_OPACITY * shape_fade;
+ self.draw_shadow(
+ w_dst,
+ w_px,
+ w_radius,
+ WEBCAM_SHADOW_SPREAD_FRAC * frame_min_px,
+ [0.0, WEBCAM_SHADOW_OFFSET_FRAC * frame_min_px],
+ strength,
+ );
+ }
+ if let Some(bg) = custom_bg {
+ self.draw_webcam_bg(bg, w_dst, w_px, w_radius, 0.0);
+ }
+ let cb = g.webcam_video_cb([u0, v0, u1, v1], w_valid, effect_code, blur_intensity);
+ draw_camera0(&crate::frame_geometry::with_camera_homography(cb, Some(&cam0)));
+ }
+ } else {
+ // Every planned layer, in order: shadow, then (camera 0) the custom background,
+ // then the video. Camera 0 keeps its frame, effects and mask; the extra cameras
+ // draw from `set_extra_camera_frames` without effects, and a missing one is skipped.
+ let render = [self.rw(), self.rh()];
+ let cam0_base = g.webcam_video_cb([0.0; 4], w_valid, effect_code, blur_intensity);
+ for plan in &g.camera_layers {
+ if plan.camera == 0 {
+ if !lp.has_webcam {
+ continue;
+ }
+ let video = crate::frame_geometry::camera_layer_cb(
+ plan,
+ Some(&cam0),
+ [wcw, wch],
+ [wtw as f32, wth as f32],
+ render,
+ &cam0_base,
+ );
+ if let Some(shadow) =
+ g.camera_layer_shadow(plan, &video, render, cfg.shadow, is_cutout)
+ {
+ self.draw_solid(&shadow);
+ }
+ if let Some(bg) = custom_bg {
+ let t = video.layer_fx[0];
+ self.draw_webcam_bg(bg, video.dst, video.quad_px, video.radius_px, t);
+ }
+ draw_camera0(&video);
+ } else {
+ let Some(cam) = extra_cams.get(plan.camera - 1).and_then(|c| c.as_ref())
+ else {
+ continue;
+ };
+ let settings = scene_ref.as_ref().and_then(|s| s.camera(plan.camera));
+ let valid = [
+ cam.visible[0] / cam.tex[0].max(1.0),
+ cam.visible[1] / cam.tex[1].max(1.0),
+ ];
+ let video = crate::frame_geometry::camera_layer_cb(
+ plan,
+ settings,
+ cam.visible,
+ cam.tex,
+ render,
+ &crate::frame_geometry::extra_camera_base_cb(valid),
+ );
+ if let Some(shadow) =
+ g.camera_layer_shadow(plan, &video, render, cfg.shadow, false)
+ {
+ self.draw_solid(&shadow);
+ }
+ self.draw_video(&video, &cam.y, &cam.uv);
+ }
+ }
}
+ drop(mask);
// --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est
// monté après la vidéo). Ancrées sur `s_ann`, le rect ÉCRAN SANS ZOOM — c'est le conteneur
@@ -2334,12 +2652,13 @@ impl Compositor {
// la transform, donc les annotations restent en place pendant que le contenu zoome dessous.
// Ce fut `s_dst` tant que le zoom vivait dans la coupe source ; depuis l'issue #179 il vit
// dans la BOÎTE, et `s_dst` emmenait annotations et sous-titres avec lui.
- // Exception : le flou de confidentialité suit le contenu (`FrameGeometry::privacy_mask`),
- // d'où la géométrie entière passée en plus de `s_ann`.
+ // Les flous de confidentialité, eux, sont passés avant le curseur (plus haut) : ils suivent
+ // le contenu (`FrameGeometry::privacy_mask`), d'où la géométrie entière passée en plus de
+ // `s_ann`.
// `source_t`, la même base de temps que les zoom/speed regions : le temps SOURCE du clip,
// pas le compteur de frames. C'est ce qui garde une annotation alignée sur l'image quand
// une speed region répète ou saute des frames.
- self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g);
+ self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g, false);
Ok(())
}
@@ -2351,19 +2670,23 @@ impl Compositor {
/// sous-titres sous un zoom (issue #179, puis #397 sur Linux). L'arithmétique elle-même vit
/// dans `frame_geometry::annotation_dst_in`, partagée par les trois backends. Le flou, lui,
/// se place par `g.privacy_mask` : un masque doit rester sur ce qu'il cache.
+ ///
+ /// `privacy` : les flous de confidentialité seuls, dessinés sur le métrage avant le curseur ;
+ /// sinon toutes les autres annotations, le calque le plus haut.
unsafe fn draw_annotations(
&self,
scene: Option<&Scene>,
t: f32,
s_ann: [f32; 4],
g: &crate::frame_geometry::FrameGeometry,
+ privacy: bool,
) {
let Some(scene) = scene else { return };
if scene.annotations.is_empty() {
return;
}
let visible = |a: &crate::scene::SceneAnnotation| {
- t >= a.start_sec as f32 && t < a.end_sec as f32
+ t >= a.start_sec as f32 && t < a.end_sec as f32 && (a.kind == "blur") == privacy
};
// Une seule recopie du render target pour TOUTES les annotations flou de la frame — leur
// lecture doit voir l'image composée sans les flous eux-mêmes, sinon deux zones qui se
@@ -2534,6 +2857,13 @@ impl Compositor {
if text.content.trim().is_empty() {
continue;
}
+ // The desk label shows only while the camera is covered: skip the rest of
+ // its section before rasterizing anything.
+ if crate::text_anim::is_desk_cover(text.animation.as_deref())
+ && g.webcam_cover <= 0.0
+ {
+ continue;
+ }
// `font_size_rel` est une fraction de la HAUTEUR DE LA BOÎTE D'ANCRAGE — rect
// écran, ou cadre de sortie pour un sous-titre (cf. le contrat et
// `annotationScale.ts`) : on la ramène en pixels de sortie ici, avec le même
@@ -2579,10 +2909,13 @@ impl Compositor {
// dispose ici) : dans une région accélérée, elle défile donc au rythme du
// clip. À vitesse 1 — le cas de toutes les annotations existantes — c'est
// exactement le timing de l'aperçu DOM.
- let anim = crate::text_anim::text_animation_state(
+ // The desk label is the exception: its opacity is the camera cover, which
+ // runs on the screen clock (`annotation_text_state`).
+ let anim = crate::text_anim::annotation_text_state(
text.animation.as_deref(),
(t - annotation.start_sec as f32) * 1000.0,
((annotation.end_sec - annotation.start_sec) * 1000.0) as f32,
+ g.webcam_cover,
);
// Les décalages sont donnés à la hauteur de référence : on les ramène à la
// sortie, comme la taille de police, pour que l'animation ait la même
@@ -2917,6 +3250,12 @@ impl Compositor {
Ok((rw, rh, out))
}
+ /// Le RT de la dernière composition, à `render_size()`, pour qui le copie ailleurs sans
+ /// passer par la RAM : l'anneau de textures partagées de la preview (`shared_frames`).
+ pub fn render_target(&self) -> &ID3D11Texture2D {
+ &self.rt
+ }
+
/// Comme `rgb_to_nv12`, mais vers `target_w`×`target_h` : si la cible diffère de la taille
/// de rendu, le RT composé est d'abord redimensionné (bilinéaire, `ps_tex`/`sampler` déjà
/// utilisés partout ailleurs dans le fichier, cf. `blit_resized`). Le RT suivant la sortie,
@@ -3162,6 +3501,9 @@ impl Compositor {
/// (p.ex. après un export) pour ne pas retenir indéfiniment des textures de pool.
pub fn clear_srv_cache(&self) {
self.srv_cache.borrow_mut().clear();
+ // The extra cameras' frames belong to the decoders just closed: forget them too, so
+ // a stale pointer is never read before the next `set_extra_camera_frames`.
+ self.extra_camera_frames.set([0; crate::camera_layers::MAX_EXTRA_CAMERAS]);
}
}
@@ -3249,6 +3591,62 @@ mod tests {
);
}
+ /// Un NV12 sans données, à la taille voulue : tout ce que `nv12_srvs` lit d'une frame.
+ fn nv12_frame(gpu: &crate::d3d::Gpu, w: u32, h: u32) -> (Box, ID3D11Texture2D) {
+ let desc = D3D11_TEXTURE2D_DESC {
+ Width: w,
+ Height: h,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: DXGI_FORMAT_NV12,
+ SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
+ Usage: D3D11_USAGE_DEFAULT,
+ BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
+ CPUAccessFlags: 0,
+ MiscFlags: 0,
+ };
+ unsafe {
+ let mut tex: Option = None;
+ gpu.device.CreateTexture2D(&desc, None, Some(&mut tex)).expect("texture NV12");
+ let tex = tex.expect("texture NV12");
+ let mut frame: Box = Box::new(std::mem::zeroed());
+ frame.data[0] = tex.as_raw() as *mut u8;
+ frame.data[1] = std::ptr::null_mut();
+ (frame, tex)
+ }
+ }
+
+ /// Un décodeur remplacé sans `clear_srv_cache` (changement de résolution en cours de flux,
+ /// `CpuFrames::ensure_tex`) peut donner à une texture NEUVE l'adresse d'une ancienne. La
+ /// copie privée gardée pour l'ancienne est alors trop petite, `CopySubresourceRegion` la
+ /// saute sans erreur et l'image gèle. On ne peut pas choisir l'adresse d'une texture ;
+ /// l'entrée de la petite est donc replacée sous la clé de la grande, l'état exact où le
+ /// cache se retrouve après un tel recyclage.
+ #[test]
+ fn a_larger_source_at_a_cached_address_gets_a_copy_of_its_own_size() {
+ let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else {
+ eprintln!("pas de device D3D11 — test sauté");
+ return;
+ };
+ let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositeur");
+ let (small, _small_tex) = nv12_frame(&gpu, 64, 64);
+ let (large, _large_tex) = nv12_frame(&gpu, 128, 96);
+ let (small_key, large_key) = (small.data[0] as usize, large.data[0] as usize);
+
+ unsafe { comp.nv12_srvs(&*small) }.expect("source de 64x64");
+ {
+ let mut cache = comp.srv_cache.borrow_mut();
+ let stale = cache.remove(&small_key).expect("entrée de la petite source");
+ cache.insert(large_key, stale);
+ }
+ unsafe { comp.nv12_srvs(&*large) }.expect("source de 128x96");
+
+ let (dst, ..) = comp.srv_cache.borrow().get(&large_key).cloned().expect("entrée remplacée");
+ let mut dd = D3D11_TEXTURE2D_DESC::default();
+ unsafe { dst.GetDesc(&mut dd) };
+ assert_eq!((dd.Width, dd.Height), (128, 96), "la copie a gardé la taille de l'ancienne texture");
+ }
+
/// Un cran de padding en format Auto change la taille de rendu de la preview. `resized`
/// réalloue les cibles et garde le reste : le masque webcam, et la boîte aux lettres où le
/// worker de segmentation dépose les suivants.
diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs
index 790f23a7f..ea0e921fb 100644
--- a/crates/compositor/src/cpu_frames_windows.rs
+++ b/crates/compositor/src/cpu_frames_windows.rs
@@ -41,8 +41,9 @@ pub(crate) struct CpuFrames {
/// NV12 en mémoire système : la cible de swscale, la source de l'upload.
nv12: *mut AVFrame,
/// La texture NV12 échantillonnée par les shaders. UNE seule, réécrite à chaque
- /// frame — le `srv_cache` du compositeur (clé `(ptr, slice)`) n'a donc qu'une entrée
- /// et ne recrée jamais de SRV, contrairement au pool tournant de D3D11VA.
+ /// frame — le `srv_cache` du compositeur (clé : le pointeur de texture) n'a donc qu'une
+ /// entrée et ne recrée jamais de SRV, contrairement au pool tournant de D3D11VA. Quand
+ /// `ensure_tex` la réalloue à une autre taille, `nv12_srvs` voit l'écart et refait la copie.
// ponytail: une seule texture = le CPU peut attendre que le GPU ait fini de lire la
// frame précédente. Sur WARP tout est CPU et le pilote sérialise déjà ; si un backend
// GPU réutilise ce chemin un jour et que le Map bloque, double-bufferiser ici.
diff --git a/crates/compositor/src/extra_cameras.rs b/crates/compositor/src/extra_cameras.rs
new file mode 100644
index 000000000..0b2833c84
--- /dev/null
+++ b/crates/compositor/src/extra_cameras.rs
@@ -0,0 +1,241 @@
+//! The extra cameras (k >= 1) a camera layout region draws, shared by the live preview
+//! (`live.rs`) and the export walk (`timeline_walk.rs`).
+//!
+//! Index convention: camera 0 is the clip's `webcam`; camera k >= 1 is
+//! `additional_cameras[k - 1]`. Only the cameras a non-empty layout region shows are opened,
+//! and each is decoded only near those regions (`extra_camera_active`). Unlike camera 0, an
+//! extra camera never shortens a clip and never holds its last picture: past its end its
+//! layer disappears.
+
+use crate::camera_layers::MAX_EXTRA_CAMERAS;
+use crate::ffi::AVFrame;
+use crate::live::PREFETCH_LEAD_SEC;
+use crate::pipeline::Decoder;
+use crate::scene::{SceneCameraLayoutRegion, SceneClipCamera};
+use crate::timeline_walk::{frame_step, FrameStep, NextFrameTime};
+use anyhow::Result;
+use std::collections::{HashMap, HashSet};
+
+/// An extra camera of an export clip: its file and its offset
+/// (camera source time = screen source time - `offset_sec`).
+pub type ClipCamera = SceneClipCamera;
+
+/// What a clip opens for its extra cameras: index k-1 = camera k, `None` = not opened (no
+/// layout region of the clip shows it, or the clip has no file for it). No extra camera at all
+/// is the empty list.
+pub(crate) type ExtraCameraKeys = Vec>;
+
+/// Re-seek an extra camera instead of decoding forward when its target is further ahead than
+/// this (it sat idle between two regions).
+pub(crate) const EXTRA_RESEEK_SEC: f64 = 1.0;
+
+/// Camera indices >= 1 that the non-empty `regions` draw, sorted, without duplicates.
+pub(crate) fn cameras_in_regions(regions: &[SceneCameraLayoutRegion]) -> Vec {
+ let mut cameras: Vec = regions
+ .iter()
+ .filter(|r| r.end_sec > r.start_sec)
+ .flat_map(|r| r.layers.iter().map(|l| l.camera))
+ .filter(|camera| (1..=MAX_EXTRA_CAMERAS).contains(camera))
+ .collect();
+ cameras.sort_unstable();
+ cameras.dedup();
+ cameras
+}
+
+pub(crate) fn has_camera_file(camera: Option<&SceneClipCamera>) -> bool {
+ camera.is_some_and(|c| !c.path.trim().is_empty())
+}
+
+/// The slots to open for `cameras` from a clip's camera files (`sources[k-1]` = camera k).
+pub(crate) fn extra_camera_keys(cameras: &[usize], sources: &[SceneClipCamera]) -> ExtraCameraKeys {
+ let mut keys: ExtraCameraKeys = (1..=MAX_EXTRA_CAMERAS)
+ .map(|k| {
+ let source = sources.get(k - 1);
+ if cameras.contains(&k) && has_camera_file(source) {
+ source.cloned()
+ } else {
+ None
+ }
+ })
+ .collect();
+ while keys.last().is_some_and(|k| k.is_none()) {
+ keys.pop();
+ }
+ keys
+}
+
+/// Is extra camera `camera` near one of its layout regions at screen source time `t` — from
+/// `PREFETCH_LEAD_SEC` before the region to its end? Outside, its decoder stays idle.
+pub(crate) fn extra_camera_active(regions: &[SceneCameraLayoutRegion], camera: usize, t: f64) -> bool {
+ regions.iter().any(|r| {
+ r.end_sec > r.start_sec
+ && r.layers.iter().any(|l| l.camera == camera)
+ && (r.start_sec - PREFETCH_LEAD_SEC..=r.end_sec).contains(&t)
+ })
+}
+
+/// A camera's source time at screen source time `screen_t` (`camera = screen - offset`),
+/// never before the file's start. Camera 0 (`live.rs`) and the extra cameras share it.
+pub(crate) fn camera_source_time(screen_t: f64, offset_sec: f64) -> f64 {
+ (screen_t - offset_sec).max(0.0)
+}
+
+/// An extra camera's open result: a file that will not open is `None` with one warning line,
+/// and its layer is skipped — never drawn from another source.
+pub(crate) fn opened_or_skipped(path: &str, opened: Result) -> Option {
+ match opened {
+ Ok(dec) => Some(dec),
+ Err(e) => {
+ eprintln!("WARNING: extra camera unreadable ({path}): {e:#}. Its layer will not be drawn.");
+ None
+ }
+ }
+}
+
+/// Opens `path` into `decs` once. A file that will not open is remembered in `unreadable`
+/// and never tried again, so its layer is skipped for the whole export. `true` when `decs`
+/// holds a decoder for `path`.
+pub(crate) fn open_once(
+ decs: &mut HashMap,
+ unreadable: &mut HashSet,
+ path: &str,
+ open: impl FnOnce(&str) -> Result,
+) -> bool {
+ if decs.contains_key(path) {
+ return true;
+ }
+ if unreadable.contains(path) {
+ return false;
+ }
+ match opened_or_skipped(path, open(path)) {
+ Some(dec) => {
+ decs.insert(path.to_string(), dec);
+ true
+ }
+ None => {
+ unreadable.insert(path.to_string());
+ false
+ }
+ }
+}
+
+/// The list `set_extra_camera_frames` takes: null for an empty slot, else `frame` of the camera
+/// (itself null when it has nothing to show).
+pub(crate) fn extra_frame_list(
+ extra: &[Option],
+ frame: impl Fn(&T) -> *const AVFrame,
+) -> Vec<*const AVFrame> {
+ extra.iter().map(|slot| slot.as_ref().map_or(std::ptr::null(), &frame)).collect()
+}
+
+/// Advances `dec` to `target` (camera source time) with the webcam's hold semantics
+/// (`frame_step`); re-seeks after an idle stretch or a jump back. Null past the camera's last
+/// frame. `ended_at` is the camera source time past which the file has no frame left: the
+/// decoder is not asked again until the target moves back before it.
+pub(crate) unsafe fn step_extra_camera(
+ dec: &mut Decoder,
+ ended_at: &mut Option,
+ target: f64,
+) -> Result<*const AVFrame> {
+ if ended_at.is_some_and(|end| target >= end) {
+ return Ok(std::ptr::null());
+ }
+ let frame_dur = 1.0 / dec.fps().max(1.0);
+ let cur = dec.cur_frame();
+ let far = cur.is_null() || {
+ let t = dec.cur_time_sec();
+ target < t - frame_dur * 0.5 || target > t + EXTRA_RESEEK_SEC
+ };
+ let frame = if far {
+ dec.seek_to(target)?
+ } else {
+ let mut frame = cur;
+ let mut guard = 0u32;
+ loop {
+ let next = dec.peek_next_time_sec()?;
+ // Unlike camera 0, an extra camera does not hold its last picture: past its
+ // end (one frame's worth of slack) its layer disappears.
+ if matches!(next, NextFrameTime::Eof) && target > dec.cur_time_sec() + frame_dur {
+ frame = std::ptr::null_mut();
+ break;
+ }
+ match frame_step(next, 0.0, target) {
+ FrameStep::Commit => frame = dec.commit_peek()?,
+ FrameStep::CommitAndStop => {
+ frame = dec.commit_peek()?;
+ break;
+ }
+ FrameStep::Hold => break,
+ }
+ guard += 1;
+ if guard > 1000 {
+ break;
+ }
+ }
+ frame
+ };
+ Ok(settle(ended_at, frame, target))
+}
+
+/// Seeks `dec` to `target` (camera source time). Null past the camera's last frame.
+pub(crate) unsafe fn seek_extra_camera(
+ dec: &mut Decoder,
+ ended_at: &mut Option,
+ target: f64,
+) -> Result<*const AVFrame> {
+ if ended_at.is_some_and(|end| target >= end) {
+ return Ok(std::ptr::null());
+ }
+ let frame = dec.seek_to(target)?;
+ Ok(settle(ended_at, frame, target))
+}
+
+fn settle(ended_at: &mut Option, frame: *mut AVFrame, target: f64) -> *const AVFrame {
+ *ended_at = if frame.is_null() { Some(target) } else { None };
+ frame
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use anyhow::anyhow;
+
+ #[test]
+ fn export_skips_an_extra_camera_that_will_not_open() {
+ let mut decs: HashMap = HashMap::new();
+ let mut unreadable = HashSet::new();
+ let mut attempts = 0;
+ let mut refuse = |_: &str| -> Result {
+ attempts += 1;
+ Err(anyhow!("no such file"))
+ };
+ assert!(!open_once(&mut decs, &mut unreadable, "/cam2.mp4", &mut refuse));
+ // A later clip of the same export does not try the file again.
+ assert!(!open_once(&mut decs, &mut unreadable, "/cam2.mp4", &mut refuse));
+ assert_eq!(attempts, 1);
+ assert!(decs.is_empty());
+
+ // A readable camera opens once and is reused by the next clip.
+ let mut opens = 0;
+ let mut accept = |_: &str| -> Result {
+ opens += 1;
+ Ok(7)
+ };
+ assert!(open_once(&mut decs, &mut unreadable, "/cam3.mp4", &mut accept));
+ assert!(open_once(&mut decs, &mut unreadable, "/cam3.mp4", &mut accept));
+ assert_eq!(opens, 1);
+ assert_eq!(decs.get("/cam3.mp4"), Some(&7));
+ }
+
+ #[test]
+ fn extra_offsets_move_into_the_screen_clock() {
+ // camera + offset = screen: at offset 2 s, a camera frame at 0.5 s is due at 2.5 s
+ // of screen time, not before.
+ let held = camera_source_time(2.4, 2.0);
+ let due = camera_source_time(2.5, 2.0);
+ assert_eq!(frame_step(NextFrameTime::At(0.5), 0.0, held), FrameStep::Hold);
+ assert_eq!(frame_step(NextFrameTime::At(0.5), 0.0, due), FrameStep::Commit);
+ // Before the camera started, its source time clamps to its first frame.
+ assert_eq!(camera_source_time(1.0, 2.0), 0.0);
+ }
+}
diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs
index c788d92b8..9cba80d82 100644
--- a/crates/compositor/src/frame_geometry.rs
+++ b/crates/compositor/src/frame_geometry.rs
@@ -27,7 +27,7 @@
use crate::config::Cfg;
use crate::scene::{Scene, SceneCrop};
-/// Constant buffer d'un calque : **176 octets**, un par draw.
+/// Constant buffer d'un calque : **256 octets**, un par draw.
///
/// C'est le contrat partagé par les trois côtés — `cbuffer Layer` dans `shaders.hlsl`,
/// `struct Layer` dans `shaders.metal` et `vk_shaders/layer.wgsl`, et ce struct. Ils doivent
@@ -35,13 +35,15 @@ use crate::scene::{Scene, SceneCrop};
/// produit un shader qui lit `color` là où on a écrit `fx`.
///
/// `align(16)` vient de la version macOS ; sous `repr(C)` seul, les offsets sont déjà
-/// 0/16/32/40/44/48/64/80/96/112/128/144/160 des deux côtés — l'alignement Rust ne change que
+/// 0/16/32/40/44/48/64/80/96/112/128/144/160/176 des deux côtés — l'alignement Rust ne change que
/// l'adresse du struct, pas son contenu, et Windows le `copy_nonoverlapping` dans un
/// constant buffer mappé où l'alignement source est sans effet. Les deux formes étaient
/// donc compatibles ; les unifier évite qu'elles cessent de l'être.
///
/// (Le commentaire d'origine annonçait « 64 octets ». Il n'a jamais été juste : dix champs,
-/// trente-deux `f32`. Les trois derniers, le flou de mouvement de l'écran incliné, en font 176.)
+/// trente-deux `f32`. Les trois suivants, le flou de mouvement de l'écran incliné, en font 176, et `cover`, le voile de la vue bureau, 192.)
+/// (The camera layers' homography `persp` and transparency `layer_fx` make it 256: offsets 192
+/// and 240.)
#[repr(C, align(16))]
#[derive(Clone, Copy, Default)]
pub struct LayerCB {
@@ -62,6 +64,28 @@ pub struct LayerCB {
pub trail_a: [f32; 4],
pub trail_b: [f32; 4],
pub trail_mb: [f32; 4],
+ /// Desk-view cover of the webcam layer: x = strength 0..1, y = blur radius (quad px),
+ /// z = dim factor, w unused. Zero everywhere else.
+ pub cover: [f32; 4],
+ /// Camera (mode 0) only: the rows of the homography H that maps a point of the layer's
+ /// `dst` quad (0..1, top-left origin) to the camera frame (0..1 of its valid part):
+ /// `[h0, h1, h2, 0], [h3, h4, h5, 0], [h6, h7, h8, 0]`. Read only when `layer_fx.y` is 1;
+ /// `src` is then ignored. Every other mode ignores it.
+ pub persp: [[f32; 4]; 3],
+ /// x = transparency 0..1 (0 = opaque), applied to the output of every mode; y = 1 when
+ /// `persp` applies; z, w = 0. All zero = the layer draws exactly as before the lanes.
+ pub layer_fx: [f32; 4],
+}
+
+impl LayerCB {
+ /// Le calque est l'un des modèles 3D — le curseur modelé (mode 15), l'impact de son clic
+ /// (16), l'appareil et son ombre (17) — que seule la variante « modèles » du shader de calque
+ /// compile. Dans le même shader que les autres, leurs branches fixaient l'occupation de TOUS
+ /// les calques (168 VGPR sur RADV) ; les trois backends décident donc du shader d'un calque
+ /// par ce test, au moment de le dessiner (cf. le haut de `vk_shaders/layer.wgsl`).
+ pub fn needs_models(&self) -> bool {
+ self.mode > 14.5 && self.mode < 17.5
+ }
}
pub const OUT_W: u32 = 1920;
@@ -319,6 +343,33 @@ pub(crate) fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f
(u0, v0, u1, v1)
}
+/// How the webcam texture is laid onto its quad this frame. The backends swap the source
+/// rect's u bounds for `flip_u` and its v bounds for `flip_v` — 180° is both — and drop the
+/// webcam crop for `full_frame`. Derived per frame because a Full Camera region can turn
+/// the camera for a desk shot.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct WebcamOrientation {
+ pub flip_u: bool,
+ pub flip_v: bool,
+ pub full_frame: bool,
+}
+
+pub fn webcam_orientation(
+ region: Option<&crate::scene::SceneCameraFullscreenRegion>,
+ layout_mirror: bool,
+) -> WebcamOrientation {
+ let Some(r) = region else {
+ return WebcamOrientation { flip_u: layout_mirror, flip_v: false, full_frame: false };
+ };
+ let turned = r.rotation == 180;
+ let mirror = r.mirror.unwrap_or(layout_mirror);
+ WebcamOrientation {
+ flip_u: mirror ^ turned,
+ flip_v: turned,
+ full_frame: turned && r.full_frame,
+ }
+}
+
/// Camera equivalent of the screen crop pipeline: apply the user crop first, then a centred
/// cover-crop inside that authored window so arbitrary layout slots never stretch the image.
pub(crate) fn webcam_source_rect(
@@ -335,6 +386,140 @@ pub(crate) fn webcam_source_rect(
box_ar,
)
}
+
+/// The camera's perspective correction, when it has one whose nine values are all finite. A
+/// matrix with a NaN or an infinity would draw garbage (or nothing), so it counts as none.
+pub(crate) fn camera_homography(camera: Option<&crate::scene::SceneCamera>) -> Option<[f32; 9]> {
+ camera
+ .and_then(|c| c.homography)
+ .filter(|h| h.iter().all(|v| v.is_finite()))
+}
+
+/// The source rect a camera is drawn from: its crop, then a cover-crop to `box_ar`, then its
+/// rotation (180° = both bounds swapped) and mirror (u bounds swapped), `flip_u = mirror ^
+/// turned` as `webcam_orientation` does. A camera without settings is drawn upright and
+/// unmirrored. With a homography, crop, rotation and mirror are all ignored: the corner order
+/// already defines the picture (the shader then does not read `src` at all).
+pub(crate) fn camera_source_rect(
+ camera: Option<&crate::scene::SceneCamera>,
+ visible: [f32; 2],
+ tex: [f32; 2],
+ box_ar: f32,
+) -> [f32; 4] {
+ let corrected = camera_homography(camera).is_some();
+ let settings = camera.filter(|_| !corrected);
+ let [u0, v0, u1, v1] = webcam_source_rect(visible, tex, settings.and_then(|c| c.crop), box_ar);
+ let turned = settings.is_some_and(|c| c.rotation == 180);
+ let mirror = settings.and_then(|c| c.mirror).unwrap_or(false);
+ let (u0, u1) = if mirror ^ turned { (u1, u0) } else { (u0, u1) };
+ let (v0, v1) = if turned { (v1, v0) } else { (v0, v1) };
+ [u0, v0, u1, v1]
+}
+
+/// The window of a corrected picture (`aspect` = its width/height) a box of ratio `box_ar`
+/// shows, as `[u0, v0, du, dv]` of the picture's unit square: centred and cover-fitted, so the
+/// picture is cropped, never stretched — what `cover_uv_rect` does for an uncorrected camera,
+/// whose `src` the shader stops reading once a homography is set. The whole picture without a
+/// usable aspect or box.
+pub(crate) fn corrected_cover_window(aspect: Option, box_ar: f32) -> [f32; 4] {
+ let Some(aspect) = aspect.filter(|a| a.is_finite() && *a > 0.0) else {
+ return [0.0, 0.0, 1.0, 1.0];
+ };
+ if !(box_ar.is_finite() && box_ar > 0.0) {
+ return [0.0, 0.0, 1.0, 1.0];
+ }
+ if box_ar > aspect {
+ let dv = aspect / box_ar;
+ [0.0, (1.0 - dv) * 0.5, 1.0, dv]
+ } else {
+ let du = box_ar / aspect;
+ [(1.0 - du) * 0.5, 0.0, du, 1.0]
+ }
+}
+
+/// `cb` with the camera's homography in `persp` and `layer_fx.y = 1`; unchanged without one.
+/// The matrix is composed with `corrected_cover_window` for the box `cb.quad_px`, so a box
+/// whose ratio is not the camera's `aspect` (a frame-filling layer, a half of side-by-side)
+/// crops the corrected picture instead of stretching it.
+pub(crate) fn with_camera_homography(
+ mut cb: LayerCB,
+ camera: Option<&crate::scene::SceneCamera>,
+) -> LayerCB {
+ if let Some(h) = camera_homography(camera) {
+ let box_ar = cb.quad_px[0] / cb.quad_px[1];
+ let [u0, v0, du, dv] = corrected_cover_window(camera.and_then(|c| c.aspect), box_ar);
+ // H * C, with C = [[du, 0, u0], [0, dv, v0], [0, 0, 1]] taking box uv to picture uv.
+ let h: [f32; 9] = std::array::from_fn(|i| {
+ let (r, c) = (i / 3, i % 3);
+ match c {
+ 0 => h[r * 3] * du,
+ 1 => h[r * 3 + 1] * dv,
+ _ => h[r * 3] * u0 + h[r * 3 + 1] * v0 + h[r * 3 + 2],
+ }
+ });
+ cb.persp = [
+ [h[0], h[1], h[2], 0.0],
+ [h[3], h[4], h[5], 0.0],
+ [h[6], h[7], h[8], 0.0],
+ ];
+ cb.layer_fx[1] = 1.0;
+ }
+ cb
+}
+
+/// The video draw of an extra camera before `camera_layer_cb` places it: mode 0, no background
+/// effect (`fx.zw = 0`), `valid` = the valid fraction of its decoder texture, no motion trail.
+pub(crate) fn extra_camera_base_cb(valid: [f32; 2]) -> LayerCB {
+ LayerCB {
+ mode: 0.0,
+ color: [0.0, 0.0, 0.0, 1.0],
+ fx: [valid[0], valid[1], 0.0, 0.0],
+ mb: [1.0, 0.0, 1.0, 0.0],
+ ..Default::default()
+ }
+}
+
+/// The video draw of one planned camera layer: `base` (camera 0's `webcam_video_cb`, or
+/// `extra_camera_base_cb` for the others) moved to the plan's rect, with its corners, its
+/// source rect (`camera_source_rect`), its homography and its transparency (`1 - opacity`).
+/// Planned layers have no motion trail: `dst_prev = dst` and one tap. The exception is camera
+/// 0's untouched default layer (the plan's rect is `base.dst`, fully opaque — what a frame
+/// outside every layout region plans): it keeps `base`'s trail, as on today's path.
+pub(crate) fn camera_layer_cb(
+ plan: &crate::camera_layers::CameraLayerPlan,
+ camera: Option<&crate::scene::SceneCamera>,
+ visible_px: [f32; 2],
+ tex_px: [f32; 2],
+ render: [f32; 2],
+ base: &LayerCB,
+) -> LayerCB {
+ let quad_px = [plan.dst[2] * render[0], plan.dst[3] * render[1]];
+ let min_px = quad_px[0].min(quad_px[1]);
+ let src = camera_source_rect(camera, visible_px, tex_px, quad_px[0] / quad_px[1].max(0.0001));
+ let cover = base.cover[0];
+ let untouched = plan.dst == base.dst && plan.opacity >= 1.0;
+ let (dst_prev, mb) = if untouched {
+ (base.dst_prev, base.mb)
+ } else {
+ (plan.dst, [1.0, 0.0, base.mb[2], base.mb[3]])
+ };
+ with_camera_homography(
+ LayerCB {
+ dst: plan.dst,
+ src,
+ quad_px,
+ radius_px: plan.radius_frac * min_px,
+ src_prev: src,
+ dst_prev,
+ mb,
+ cover: [cover, 0.04 * min_px * cover, base.cover[2], base.cover[3]],
+ persp: [[0.0; 4]; 3],
+ layer_fx: [1.0 - plan.opacity.clamp(0.0, 1.0), 0.0, 0.0, 0.0],
+ ..*base
+ },
+ camera,
+ )
+}
/// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son
/// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la
/// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui
@@ -1590,6 +1775,54 @@ fn same_source_path(a: &str, b: &str) -> bool {
/// shader un temps borné, là où un `f32` de plusieurs heures perdrait la finesse du bruit.
pub const WALLPAPER_MOTION_PERIOD_S: f32 = 120.0;
+/// Ce qui décide à lui seul du fond composé — sa description, la force de son flou, la couleur
+/// de repli, la taille de rendu —, gardé avec sa copie (`bg_cache`) : tant que le fond demandé
+/// lui est égal, le backend recopie le fond de la frame d'avant au lieu de le redessiner, ce qui
+/// coûtait plus que l'écran lui-même (1,65 ms par frame 1080p sur une Radeon 610M, un wallpaper
+/// 4K échantillonné sans mips).
+///
+/// La comparaison de chaque frame (`matches`) ne construit ni ne copie rien : une image en data
+/// URI pèse plusieurs Mo. Seul `of`, au moment de garder un nouveau fond, la clone.
+#[derive(Clone, PartialEq)]
+pub struct BackgroundKey {
+ background: Option,
+ blur: f32,
+ fallback: [f32; 4],
+ render_px: [f32; 2],
+}
+
+impl BackgroundKey {
+ /// La clé d'un fond fixe ; `None` pour un fond animé, qui change à chaque frame.
+ pub fn of(
+ background: Option<&crate::scene::SceneBackground>,
+ blur: f32,
+ fallback: [f32; 4],
+ render_px: [f32; 2],
+ ) -> Option {
+ use crate::scene::{SceneBackground as B, WallpaperMotion};
+ if let Some(B::Gradient { motion, .. } | B::Image { motion, .. }) = background {
+ if *motion != WallpaperMotion::None {
+ return None;
+ }
+ }
+ Some(BackgroundKey { background: background.cloned(), blur, fallback, render_px })
+ }
+
+ /// Le fond demandé est celui-ci.
+ pub fn matches(
+ &self,
+ background: Option<&crate::scene::SceneBackground>,
+ blur: f32,
+ fallback: [f32; 4],
+ render_px: [f32; 2],
+ ) -> bool {
+ self.background.as_ref() == background
+ && self.blur == blur
+ && self.fallback == fallback
+ && self.render_px == render_px
+ }
+}
+
/// Emplacements libres des modes 5 (dégradé) et 6 (image) pour le fond animé : `fx.zw` = (temps
/// programme replié, indice du mouvement), `mb.x` = aspect w/h du rect rempli (les nappes de
/// l'aurore restent rondes).
@@ -1849,6 +2082,14 @@ pub struct FrameGeometry {
pub w_px: [f32; 2],
pub w_radius: f32,
pub shape_fade: f32,
+ /// Orientation of the webcam this frame (mirror, desk-shot turn, crop bypass).
+ pub webcam: WebcamOrientation,
+ /// Desk-view cover strength of the webcam this frame, 0..1 (`camera_fullscreen_cover_at`).
+ pub webcam_cover: f32,
+ /// Every camera layer to draw this frame, in draw order (`camera_layers::camera_layers_at`).
+ /// Empty when the scene has no camera layout regions: the backends then draw camera 0 from
+ /// `w_dst` as before. Otherwise camera 0 is in here too, and its `dst`/opacity come from it.
+ pub camera_layers: Vec,
/// Cadre autour de l'écran : chrome de fenêtre plat (mode 14) ou appareil modelé (mode 17).
/// `None` : aucun, et le rendu est celui d'avant le cadre, à l'octet. `Some` : `s_dst` est
/// déjà la boîte rétrécie, et `s_radius` le rayon des coins de l'écran — des seuls coins BAS
@@ -2087,6 +2328,109 @@ impl FrameGeometry {
self.tilt_trail(render_px).filter(|_| !self.screen_trail(render_px))
}
+ /// The camera-0 video draw (mode 0), the same on every backend. `src` = the source rect with
+ /// the mirror / desk turn already applied as swapped bounds (it is also `src_prev`: only
+ /// `dst_prev` carries the motion), `valid` = the valid fraction of the decoder texture,
+ /// `effect_code` / `blur_intensity` = the background effect sent to the shader.
+ pub fn webcam_video_cb(
+ &self,
+ src: [f32; 4],
+ valid: [f32; 2],
+ effect_code: f32,
+ blur_intensity: f32,
+ ) -> LayerCB {
+ LayerCB {
+ dst: self.w_dst,
+ src,
+ quad_px: self.w_px,
+ radius_px: self.w_radius,
+ mode: 0.0,
+ // `color.a` carries the cutout alpha (`color.a * person`); the RGB is not read, the
+ // background has already been painted under the camera.
+ color: [0.0, 0.0, 0.0, 1.0],
+ fx: [valid[0], valid[1], effect_code, blur_intensity],
+ src_prev: src,
+ dst_prev: self.w_dst_prev,
+ mb: [self.mb_taps, self.mb_amount, 1.0, 0.0],
+ cover: [
+ self.webcam_cover,
+ 0.04 * self.w_px[0].min(self.w_px[1]) * self.webcam_cover,
+ 0.35,
+ 0.0,
+ ],
+ ..Default::default()
+ }
+ }
+
+ /// Camera 0's settings as a `SceneCamera`, for `camera_source_rect` / `camera_layer_cb`:
+ /// its orientation is the project mirror and the desk-view turn (`self.webcam`, expressed so
+ /// that `mirror ^ turned` gives back `flip_u`), its crop is the layout's (none for a
+ /// full-frame desk shot), and only its homography and the corrected picture's aspect (so a
+ /// box of another ratio cover-crops it, `with_camera_homography`) come from
+ /// `Scene::camera(0)`.
+ pub(crate) fn camera0_settings(&self, scene: Option<&Scene>) -> crate::scene::SceneCamera {
+ let turned = self.webcam.flip_v;
+ let settings = scene.and_then(|s| s.camera(0));
+ crate::scene::SceneCamera {
+ index: 0,
+ rotation: if turned { 180 } else { 0 },
+ mirror: Some(self.webcam.flip_u ^ turned),
+ crop: if self.webcam.full_frame {
+ None
+ } else {
+ scene.and_then(|s| s.layout.webcam_crop)
+ },
+ homography: settings.and_then(|c| c.homography),
+ aspect: settings.and_then(|c| c.aspect),
+ }
+ }
+
+ /// The drop shadow under a planned camera layer, `video` being its `camera_layer_cb`. Same
+ /// constants as camera 0's PiP shadow, and it fades with the layer (`video.layer_fx.x`).
+ /// None when `shadows_on` is off or the layer fills the frame. Camera 0 also keeps today's
+ /// rules: no shadow in the block presets nor in cutout (`camera0_cutout`), and it leaves
+ /// with the bubble (`shape_fade`).
+ pub(crate) fn camera_layer_shadow(
+ &self,
+ plan: &crate::camera_layers::CameraLayerPlan,
+ video: &LayerCB,
+ render: [f32; 2],
+ shadows_on: bool,
+ camera0_cutout: bool,
+ ) -> Option {
+ let strength = if plan.camera == 0 {
+ let block = matches!(
+ self.scene_preset.as_deref(),
+ Some("dual-frame") | Some("vertical-stack")
+ );
+ if block || camera0_cutout {
+ return None;
+ }
+ WEBCAM_SHADOW_OPACITY * self.shape_fade
+ } else {
+ WEBCAM_SHADOW_OPACITY
+ };
+ if !shadows_on || plan.fills_frame || strength <= 0.0 {
+ return None;
+ }
+ let spread = WEBCAM_SHADOW_SPREAD_FRAC * self.frame_min_px;
+ let offset = WEBCAM_SHADOW_OFFSET_FRAC * self.frame_min_px;
+ let (sx, sy) = (spread / render[0].max(1.0), spread / render[1].max(1.0));
+ let oy = offset / render[1].max(1.0);
+ let dst = video.dst;
+ Some(LayerCB {
+ dst: [dst[0] - sx, dst[1] - sy + oy, dst[2] + 2.0 * sx, dst[3] + 2.0 * sy],
+ quad_px: [video.quad_px[0] + 2.0 * spread, video.quad_px[1] + 2.0 * spread],
+ radius_px: video.radius_px,
+ mode: 2.0,
+ color: [0.0, 0.0, 0.0, strength],
+ fx: [spread, 0.0, 0.0, 0.0],
+ mb: [0.0, 1.0, 1.0, 0.0],
+ layer_fx: [video.layer_fx[0], 0.0, 0.0, 0.0],
+ ..Default::default()
+ })
+ }
+
/// Le calque du mode 18 : le rendu isolé de l'écran cadré (t2), recomposé le long de sa
/// trajectoire. `src` = la coupe (le métrage relu directement là où le rendu isolé s'arrête
/// au bord de la sortie), `quad_px` et `radius_px` = l'écran et ses coins : ce repli ne lit le
@@ -2658,10 +3002,6 @@ impl FrameGeometry {
[centre[0] + px, centre[1] + py]
};
let pts = [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)];
- // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt
- // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque couvre alors
- // la boîte englobante du secret aux deux frames, en rect droit, comme le chemin droit.
- // Même warp que le shader sur les coins d'avant (le drapeau projectif est celui du plan).
// La perspective grossit localement le côté proche : la force suit l'arête la plus
// agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus
// fin, rapporté au contenu, qu'au repos.
@@ -2672,28 +3012,29 @@ impl FrameGeometry {
.max(len(pts[0], pts[3]) / eh)
.max(len(pts[1], pts[2]) / eh);
let strength = (zoom_k * tilt_k).max(1.0);
- // Sans mouvement, la traînée EST le quad (au bit près) : rien de plus à couvrir.
- if let Some(trail) = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners) {
- let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad };
- let then = |fx: f32, fy: f32| {
- let (px, py) = before.point_px(fx, fy);
- [centre[0] + px, centre[1] + py]
- };
- let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
- for [px, py] in pts.into_iter().chain([then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)]) {
- (lo, hi) = ([lo[0].min(px), lo[1].min(py)], [hi[0].max(px), hi[1].max(py)]);
+ // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt
+ // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque garde la
+ // perspective du quad courant, élargi de la plus grande avance d'un de ses coins : chaque
+ // coin d'avant tombe dedans, donc tout le secret d'avant aussi (un quad convexe contient
+ // l'enveloppe de ses coins), et la traînée, qui va de l'un à l'autre. Même warp que le
+ // shader sur les coins d'avant (le drapeau projectif est celui du plan). Sans mouvement,
+ // la traînée EST le quad (au bit près) : rien de plus à couvrir.
+ let trail = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners);
+ let pts = match trail {
+ Some(trail) => {
+ let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad };
+ let then = |fx: f32, fy: f32| {
+ let (px, py) = before.point_px(fx, fy);
+ [centre[0] + px, centre[1] + py]
+ };
+ let prev = [then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)];
+ let reach = (0..4)
+ .map(|i| (prev[i][0] - pts[i][0]).hypot(prev[i][1] - pts[i][1]))
+ .fold(0.0f32, f32::max);
+ grow_quad(pts, reach + 0.5)
}
- let rect =
- [lo[0] / rw, lo[1] / rh, (hi[0] - lo[0]).max(1.0) / rw, (hi[1] - lo[1]).max(1.0) / rh];
- let rect = match self.screen_mask {
- Some(m) => m.clip(rect)?,
- None => rect,
- };
- let mut mask = PrivacyMask::upright(rect, render_px, strength);
- // Un ovale inscrit dans la boîte élargie ne couvrirait plus l'ovale courant.
- mask.oval_ok = false;
- return Some(mask);
- }
+ None => pts,
+ };
let (mut min_x, mut min_y) = (f32::MAX, f32::MAX);
let (mut max_x, mut max_y) = (f32::MIN, f32::MIN);
for [px, py] in pts {
@@ -2721,9 +3062,65 @@ impl FrameGeometry {
warp: Some(local),
projective: quad.projective,
strength,
- oval_ok: true,
+ // Un ovale inscrit dans le quad élargi ne couvrirait plus sûrement l'ovale d'avant.
+ oval_ok: trail.is_none(),
})
}
+
+ /// Où tombe le métrage dans l'image (`FootageQuad`) : là où `privacy_mask` pose un masque,
+ /// donc là où l'éditeur doit poser le gimbal d'un flou pour qu'il couvre ce qu'il cache.
+ pub fn footage_quad(&self, render_px: [f32; 2]) -> FootageQuad {
+ let [rw, rh] = render_px;
+ let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh];
+ if let Some(quad) = self.screen_tilt(s_px).filter(|_| self.tilted() && rw > 0.0 && rh > 0.0) {
+ let centre = self.screen_center_px(render_px);
+ let at = |fx: f32, fy: f32| {
+ let (px, py) = quad.point_px(fx, fy);
+ [(centre[0] + px) / rw, (centre[1] + py) / rh]
+ };
+ return FootageQuad {
+ corners: [at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0), at(0.0, 1.0)],
+ projective: quad.projective,
+ };
+ }
+ let [x, y, w, h] = self.s_dst;
+ FootageQuad { corners: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], projective: false }
+ }
+}
+
+/// Le métrage dans l'image : ses coins TL, TR, BR, BL en fractions de la sortie, et la
+/// correspondance d'un point `(u, v)` du métrage à l'image, bilinéaire entre eux ou, sous la
+/// caméra réelle, l'homographie qu'ils définissent (`TiltedQuad::point_px`).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct FootageQuad {
+ pub corners: [[f32; 2]; 4],
+ pub projective: bool,
+}
+
+/// Le quad convexe `pts` (TL, TR, BR, BL) dont chaque côté recule de `d` px vers l'extérieur :
+/// ses côtés restent parallèles aux siens, donc sa perspective aussi, et il contient tout point
+/// à moins de `d` de lui.
+fn grow_quad(pts: [[f32; 2]; 4], d: f32) -> [[f32; 2]; 4] {
+ // L'extérieur d'un côté est d'un bord ou de l'autre selon le sens de parcours.
+ let area: f32 = (0..4)
+ .map(|i| {
+ let (a, b) = (pts[i], pts[(i + 1) % 4]);
+ a[0] * b[1] - b[0] * a[1]
+ })
+ .sum();
+ let side = area.signum();
+ let normal = |i: usize| {
+ let (a, b) = (pts[i], pts[(i + 1) % 4]);
+ let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
+ let len = ex.hypot(ey).max(1e-6);
+ [side * ey / len, -side * ex / len]
+ };
+ std::array::from_fn(|i| {
+ // Le coin glisse sur la bissectrice jusqu'à la rencontre des deux côtés reculés.
+ let (na, nb) = (normal((i + 3) % 4), normal(i));
+ let k = d / (1.0 + na[0] * nb[0] + na[1] * nb[1]).max(0.2);
+ [pts[i][0] + (na[0] + nb[0]) * k, pts[i][1] + (na[1] + nb[1]) * k]
+ })
}
/// Marge ajoutée autour d'un masque de confidentialité, en px de la boîte écran AU REPOS : un
@@ -2873,6 +3270,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
let zoom_regions = scene.map(|s| &s.zoom_regions).unwrap_or(&empty_zoom);
let cam_regions =
scene.map(|s| &s.camera_fullscreen_regions).unwrap_or(&empty_cam);
+ // The layout regions, for the seams a Full Camera region shares with them.
+ let empty_layouts: Vec = Vec::new();
+ let layouts = scene.map(|s| &s.camera_layout_regions).unwrap_or(&empty_layouts);
let webcam_reactive = scene.map(|s| s.layout.webcam_reactive_zoom).unwrap_or(false);
let source_t = input.timeline_t_override.unwrap_or(frame / FPS);
// Les transitions se mesurent à l'écran (`ScreenClock`), la frame précédente aussi : une
@@ -2891,6 +3291,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
let mut zoom_tilt = 0.0f32;
let mut zoom_camera = 0.0f32;
let mut zoom_camera_prev = 0.0f32;
+ let mut zoom_strength = 0.0f32;
+ let mut zoom_strength_prev = 0.0f32;
let mut zoom_aim = [0.5f32; 2];
let mut zoom_orbit = [0.5f32; 2];
let (mut zoom_aim_prev, mut zoom_orbit_prev) = ([0.5f32; 2], [0.5f32; 2]);
@@ -2919,6 +3321,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
zoom_rotation = zs.rotation;
zoom_tilt = zs.tilt;
zoom_camera = zs.camera;
+ zoom_strength = zs.strength;
zoom_aim = zs.aim;
zoom_orbit = zs.orbit;
// Même `CameraFrame` qu'à la frame courante : la caméra réelle d'avant (poids, visée,
@@ -2934,24 +3337,38 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
pp.focus = zs_p.focus;
zoom_rotation_prev = zs_p.rotation;
zoom_camera_prev = zs_p.camera;
+ zoom_strength_prev = zs_p.strength;
zoom_aim_prev = zs_p.aim;
zoom_orbit_prev = zs_p.orbit;
}
// Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger
// "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens).
let cam_progress =
- crate::regions::camera_fullscreen_progress_at(cam_regions, source_t, &clock);
+ crate::regions::camera_fullscreen_progress_at(cam_regions, source_t, &clock, layouts);
let cam_progress_prev =
- crate::regions::camera_fullscreen_progress_at(cam_regions, source_t_prev, &clock);
+ crate::regions::camera_fullscreen_progress_at(
+ cam_regions,
+ source_t_prev,
+ &clock,
+ layouts,
+ );
let shape_fade =
- crate::regions::camera_fullscreen_shape_at(cam_regions, source_t, &clock);
- // rétrécissement réactif : la webcam rétrécit pendant un zoom actif, de la MOITIÉ de ce
- // que prendrait 1/zoom : ×0,78 au zoom par défaut (1,8), ×0,6 au zoom maximal (5). En
- // 1/zoom elle tombait à ×0,56 au zoom par défaut et un visage ne se lisait plus. Ignoré
- // pendant Full Camera (voir ci-dessus).
- let reactive_scale = |zoom: f32, progress: f32| -> f32 {
- if webcam_reactive && progress <= 0.0 && zoom.is_finite() && zoom > 0.0 {
- ((1.0 + 1.0 / zoom) / 2.0).min(1.0)
+ crate::regions::camera_fullscreen_shape_at(cam_regions, source_t, &clock, layouts);
+ let webcam = webcam_orientation(
+ crate::regions::camera_fullscreen_region_at(cam_regions, source_t, &clock, layouts),
+ lp.webcam_mirror,
+ );
+ let webcam_cover =
+ crate::regions::camera_fullscreen_cover_at(cam_regions, source_t, &clock, layouts);
+ // rétrécissement réactif : la webcam garde 70 % de sa taille pendant un zoom actif, quel
+ // que soit son niveau (elle suivait 1/zoom, et rétrécissait donc d'autant plus que le zoom
+ // était profond : ×0,6 au zoom maximal). L'enveloppe est celle de la région : elle descend
+ // avec le ease-in du zoom, tient 0,7 sur le span, remonte avec le ease-out. Une région à
+ // échelle 1 ne zoome rien : elle laisse la caméra à sa taille. Ignoré pendant Full Camera
+ // (voir ci-dessus).
+ let reactive_scale = |strength: f32, zoom: f32, progress: f32| -> f32 {
+ if webcam_reactive && progress <= 0.0 && zoom > 1.0 && strength.is_finite() {
+ (1.0 - 0.3 * strength.clamp(0.0, 1.0)).clamp(0.7, 1.0)
} else {
1.0
}
@@ -2965,8 +3382,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
// Seul `reactive_scale` (rétrécissement pendant un zoom, une valeur ANIMÉE par frame que
// le rect statique de l'app ne capture pas) doit encore s'appliquer dans ce cas.
let base_size_scale = if app_webcam_rect.is_some() { 1.0 } else { lp.webcam_size_scale };
- let webcam_size_scale = base_size_scale * reactive_scale(p.zoom, cam_progress);
- let webcam_size_scale_prev = base_size_scale * reactive_scale(pp.zoom, cam_progress_prev);
+ let webcam_size_scale = base_size_scale * reactive_scale(zoom_strength, p.zoom, cam_progress);
+ let webcam_size_scale_prev =
+ base_size_scale * reactive_scale(zoom_strength_prev, pp.zoom, cam_progress_prev);
// padding : échelle globale du layout autour du centre du cadre (parité web frameRenderer :
// paddingScale = 1 - padding*0.4 → padding 0 = plein cadre). S'applique à TOUS les presets :
@@ -3354,6 +3772,30 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
_ => 0.12,
},
};
+ // Camera layout regions: every camera layer of this frame, camera 0 included, on the
+ // same time and clock as Full Camera. Camera 0's default is the layer just planned
+ // above, so a region glides from and back to exactly where `w_dst` puts it. Without
+ // regions nothing is planned, and the backends keep drawing camera 0 from `w_dst`.
+ let camera_layers = match scene {
+ Some(s) if !s.camera_layout_regions.is_empty() => {
+ let default_cam0 = lp.has_webcam.then(|| crate::camera_layers::CameraLayerPlan {
+ camera: 0,
+ dst: w_dst,
+ radius_frac: w_radius / w_px[0].min(w_px[1]).max(1.0),
+ shape: lp.webcam_shape,
+ opacity: 1.0,
+ fills_frame: cam_progress >= 1.0,
+ });
+ crate::camera_layers::camera_layers_at(
+ &s.camera_layout_regions,
+ &s.camera_fullscreen_regions,
+ source_t,
+ &clock,
+ default_cam0,
+ )
+ }
+ _ => Vec::new(),
+ };
FrameGeometry {
scene_preset,
@@ -3385,6 +3827,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
w_px,
w_radius,
shape_fade,
+ webcam,
+ webcam_cover,
+ camera_layers,
window_frame,
screen_mask,
}
@@ -3419,6 +3864,10 @@ pub struct CursorPlan {
/// L'impact des clics récents sur l'écran (mode 16), à dessiner SOUS le curseur. Vide sans
/// curseur modélisé, sans clic récent ou à `clickBounce` nul.
pub impacts: Vec,
+ /// Le curseur modélisé est le cristal de Prism Glow : il réfracte l'image déjà composée, le
+ /// métrage et ses flous de confidentialité, que le backend lui copie juste avant de le
+ /// dessiner. Seuls des pixels floutés passent ainsi à travers le verre.
+ pub glass: bool,
}
impl CursorPlan {
@@ -3720,6 +4169,7 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Option, x: f32, y: f32| {
let anchor = anchor.map(|a| format!(r#","webcamAnchor":"{a}""#)).unwrap_or_default();
let layout = format!(
@@ -6490,19 +6985,56 @@ mod tests {
plan_frame(&golden_input(&scene, &cfg)).w_dst
};
for (anchor, x, y, want) in [
- (Some("bottom-right"), 0.77, 0.77, [0.82, 0.82]),
+ (Some("bottom-right"), 0.77, 0.77, [0.83, 0.83]),
(Some("top-left"), 0.03, 0.03, [0.03, 0.03]),
- (Some("bottom"), 0.4, 0.77, [0.425, 0.82]),
- (Some("left"), 0.03, 0.4, [0.03, 0.425]),
- (None, 0.77, 0.77, [0.795, 0.795]),
+ (Some("bottom"), 0.4, 0.77, [0.43, 0.83]),
+ (Some("left"), 0.03, 0.4, [0.03, 0.43]),
+ (None, 0.77, 0.77, [0.80, 0.80]),
] {
let w = camera_at(anchor, x, y);
let got = [w[0], w[1], w[2], w[3]];
- let want = [want[0], want[1], 0.15, 0.15];
+ let want = [want[0], want[1], 0.14, 0.14];
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{anchor:?} : {got:?} au lieu de {want:?}");
}
}
+ /// Le rétrécissement réactif vise 70 % QUEL QUE SOIT le niveau de zoom : la caméra garde la
+ /// même taille à 1,25×, 2× et 5× (elle suivait 1/zoom, et rétrécissait d'autant plus que le
+ /// zoom était profond).
+ #[test]
+ fn the_camera_shrink_is_the_same_at_every_zoom_level() {
+ let cfg = crate::config::all().pop().expect("au moins une config");
+ let camera_at = |scale: &str| {
+ let layout = r#""webcamReactiveZoom":true,"webcamAnchor":"bottom-right","webcamRect":{"x":0.77,"y":0.77,"width":0.2,"height":0.2}}"#;
+ let json = zoomed_golden_scene_json()
+ .replace(r#""webcamReactiveZoom":false}"#, layout)
+ .replace(r#""scale":2.0"#, scale);
+ let scene = Scene::from_json(&json).expect("scène");
+ plan_frame(&golden_input(&scene, &cfg)).w_dst
+ };
+ for scale in [r#""scale":1.25"#, r#""scale":2.0"#, r#""scale":5.0"#] {
+ let w = camera_at(scale);
+ let got = [w[0], w[1], w[2], w[3]];
+ let want = [0.83, 0.83, 0.14, 0.14];
+ assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{scale} : {got:?} au lieu de {want:?}");
+ }
+ }
+
+ /// Une région de zoom à échelle 1 ne zoome rien : elle ne rétrécit pas la caméra.
+ #[test]
+ fn a_one_x_zoom_region_keeps_the_camera_at_its_size() {
+ let cfg = crate::config::all().pop().expect("au moins une config");
+ let layout = r#""webcamReactiveZoom":true,"webcamAnchor":"bottom-right","webcamRect":{"x":0.77,"y":0.77,"width":0.2,"height":0.2}}"#;
+ let json = zoomed_golden_scene_json()
+ .replace(r#""webcamReactiveZoom":false}"#, layout)
+ .replace(r#""scale":2.0"#, r#""scale":1.0"#);
+ let scene = Scene::from_json(&json).expect("scène");
+ let w = plan_frame(&golden_input(&scene, &cfg)).w_dst;
+ let got = [w[0], w[1], w[2], w[3]];
+ let want = [0.77, 0.77, 0.2, 0.2];
+ assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} au lieu de {want:?}");
+ }
+
/// La vitesse se mesure dans la coupe VISIBLE, zoom compris : sous un x2, le même geste
/// traverse deux fois plus d'écran et penche donc plus le plan (hors saturation).
#[test]
@@ -6839,6 +7371,87 @@ mod tests {
}
}
+ /// Le point `p` est dans le quad convexe `q` (TL, TR, BR, BL, px de sortie).
+ fn in_convex_quad(q: [[f32; 2]; 4], p: [f32; 2]) -> bool {
+ let side = |i: usize| {
+ let (a, b) = (q[i], q[(i + 1) % 4]);
+ (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
+ };
+ (0..4).all(|i| side(i) >= -1e-3) || (0..4).all(|i| side(i) <= 1e-3)
+ }
+
+ /// Sous l'orbite, la caméra suit le pointeur et le flou de mouvement étale le contenu vers
+ /// sa position de la frame d'avant : le masque garde la perspective du contenu (un quad warpé
+ /// aux côtés parallèles aux siens, pas un rect droit) et couvre le secret aux deux frames.
+ #[test]
+ fn a_moving_tilted_mask_keeps_its_perspective() {
+ let cfg = crate::config::all().pop().expect("au moins une config");
+ let json = zoomed_golden_scene_json()
+ .replace(r#""rotation":"none""#, r#""rotation":"orbit","focusMode":"auto""#);
+ let scene = Scene::from_json(&json).expect("scène");
+ // Le pointeur file vers la droite : l'orbite le suit, la caméra bouge à chaque frame.
+ let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new(crate::cursor::CursorTrack::new(
+ (0..=90).map(|i| (i as f32 / 30.0, 0.05 + 0.5 * i as f32 / 90.0, 0.3)).collect(),
+ vec![],
+ vec![],
+ )));
+ let g = plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(&scene, &cfg) });
+ let render = [1170.0, 658.0];
+ let quad = g.screen_tilt([g.s_dst[2] * render[0], g.s_dst[3] * render[1]]).expect("orbite");
+ let trail = g.tilt_trail(render).expect("garde : flou de mouvement actif sous l'orbite");
+ assert_ne!(trail.corners, quad.corners, "garde : la caméra doit bouger d'une frame à l'autre");
+ let a = blur_annotation("");
+ let m = g.privacy_mask(&a, render).expect("masque");
+ let warp = m.warp.expect("le masque d'un écran incliné qui bouge reste un quad warpé");
+ let origin = [m.dst[0] * render[0], m.dst[1] * render[1]];
+ let mask = warp.map(|[x, y]| [origin[0] + x, origin[1] + y]);
+ let centre = [(g.s_dst[0] + g.s_dst[2] * 0.5) * render[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render[1]];
+ let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad };
+ for q in [quad, before] {
+ for (fx, fy) in [(a.x, a.y), (a.x + a.w, a.y), (a.x + a.w, a.y + a.h), (a.x, a.y + a.h)] {
+ let (px, py) = q.point_px(fx, fy);
+ let p = [centre[0] + px, centre[1] + py];
+ assert!(in_convex_quad(mask, p), "{p:?} hors du masque {mask:?}");
+ }
+ }
+ let (tl, tr) = (quad.point_px(a.x, a.y), quad.point_px(a.x + a.w, a.y));
+ let content = (tr.1 - tl.1) / (tr.0 - tl.0);
+ assert!(content.abs() > 1e-3, "garde : l'orbite doit incliner le haut du secret ({content})");
+ let top = (mask[1][1] - mask[0][1]) / (mask[1][0] - mask[0][0]);
+ assert!((top - content).abs() < 1e-3, "le haut du masque ({top}) doit suivre celui du contenu ({content})");
+ assert!(!m.oval_ok, "un masque élargi à la trace doit refuser l'ovale");
+ }
+
+ /// Le métrage que l'éditeur lit avec l'image : le rect zoomé à plat, le quad incliné du mode 8
+ /// sous un préset, l'homographie de ses coins sous la caméra réelle.
+ #[test]
+ fn the_footage_quad_is_where_masks_land() {
+ let cfg = crate::config::all().pop().expect("au moins une config");
+ let render = [1170.0, 658.0];
+ let flat = plan_frame(&golden_input(&zoomed_golden_scene(), &cfg));
+ let [x, y, w, h] = flat.s_dst;
+ assert_eq!(
+ flat.footage_quad(render),
+ FootageQuad { corners: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], projective: false }
+ );
+ let tilted = plan_frame(&golden_input(&tilted_golden_scene(), &cfg));
+ let q = tilted.footage_quad(render);
+ let quad = tilted.screen_tilt([tilted.s_dst[2] * render[0], tilted.s_dst[3] * render[1]]).expect("iso");
+ let centre = [
+ (tilted.s_dst[0] + tilted.s_dst[2] * 0.5) * render[0],
+ (tilted.s_dst[1] + tilted.s_dst[3] * 0.5) * render[1],
+ ];
+ for (k, (cx, cy)) in quad.corners.iter().enumerate() {
+ let want = [(centre[0] + cx) / render[0], (centre[1] + cy) / render[1]];
+ assert!((q.corners[k][0] - want[0]).abs() < 1e-5 && (q.corners[k][1] - want[1]).abs() < 1e-5, "coin {k}");
+ }
+ assert!(!q.projective);
+ let json = zoomed_golden_scene_json()
+ .replace(r#""rotation":"none""#, r#""rotation":"orbit","focusMode":"auto""#);
+ let orbit = plan_frame(&golden_input(&Scene::from_json(&json).expect("scène"), &cfg));
+ assert!(orbit.footage_quad(render).projective, "la caméra réelle warpe par homographie");
+ }
+
/// Une entrée mesurée sur le cadre de sortie n'est pas concernée par le zoom.
#[test]
fn a_privacy_mask_in_frame_space_ignores_the_zoom() {
@@ -7016,7 +7629,7 @@ mod tests {
#[test]
fn layer_cb_matches_the_shader_constant_buffer() {
use std::mem::{align_of, offset_of, size_of};
- assert_eq!(size_of::(), 176);
+ assert_eq!(size_of::(), 256);
assert_eq!(align_of::(), 16);
for (name, got, want) in [
("dst", offset_of!(LayerCB, dst), 0),
@@ -7032,11 +7645,527 @@ mod tests {
("trail_a", offset_of!(LayerCB, trail_a), 128),
("trail_b", offset_of!(LayerCB, trail_b), 144),
("trail_mb", offset_of!(LayerCB, trail_mb), 160),
+ ("cover", offset_of!(LayerCB, cover), 176),
+ ("persp", offset_of!(LayerCB, persp), 192),
+ ("layer_fx", offset_of!(LayerCB, layer_fx), 240),
] {
assert_eq!(got, want, "offset de `{name}`");
}
}
+ /// The camera-0 draw as every backend builds it today leaves both new lanes at zero: no
+ /// homography (`layer_fx.y = 0`) and opaque (`layer_fx.x = 0`), so the shaders take exactly
+ /// the path they took before the lanes existed. The other fields are the ones the three
+ /// backends used to spell out inline.
+ #[test]
+ fn the_webcam_draw_leaves_the_homography_and_transparency_lanes_at_zero() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let scene = golden_scene();
+ let g = plan_frame(&golden_input(&scene, &cfg));
+ let src = [0.1, 0.2, 0.9, 0.8];
+ let cb = g.webcam_video_cb(src, [1.0, 0.75], 2.0, 0.4);
+ assert_eq!(cb.persp, [[0.0; 4]; 3]);
+ assert_eq!(cb.layer_fx, [0.0; 4]);
+ assert_eq!(cb.dst, g.w_dst);
+ assert_eq!(cb.src, src);
+ assert_eq!(cb.src_prev, src);
+ assert_eq!(cb.quad_px, g.w_px);
+ assert_eq!(cb.radius_px, g.w_radius);
+ assert_eq!(cb.mode, 0.0);
+ assert_eq!(cb.color, [0.0, 0.0, 0.0, 1.0]);
+ assert_eq!(cb.fx, [1.0, 0.75, 2.0, 0.4]);
+ assert_eq!(cb.dst_prev, g.w_dst_prev);
+ assert_eq!(cb.mb, [g.mb_taps, g.mb_amount, 1.0, 0.0]);
+ let min_px = g.w_px[0].min(g.w_px[1]);
+ assert_eq!(cb.cover, [g.webcam_cover, 0.04 * min_px * g.webcam_cover, 0.35, 0.0]);
+ }
+
+ fn extra_camera(index: usize) -> crate::scene::SceneCamera {
+ crate::scene::SceneCamera {
+ index,
+ rotation: 0,
+ mirror: None,
+ crop: None,
+ homography: None,
+ aspect: None,
+ }
+ }
+
+ fn planned(camera: usize, opacity: f32) -> crate::camera_layers::CameraLayerPlan {
+ crate::camera_layers::CameraLayerPlan {
+ camera,
+ dst: [0.5, 0.25, 0.25, 0.5],
+ radius_frac: 0.2,
+ shape: 0,
+ opacity,
+ fills_frame: false,
+ }
+ }
+
+ /// One planned layer's video draw: placed at the plan's rect with its corners, no trail,
+ /// the transparency is `1 - opacity`, and the camera's homography replaces its crop.
+ #[test]
+ fn camera_layer_cb_places_the_layer_and_carries_its_lanes() {
+ let render = [1920.0, 1080.0];
+ let (visible, tex) = ([1280.0, 720.0], [1280.0, 736.0]);
+ let base = extra_camera_base_cb([1.0, 720.0 / 736.0]);
+ let plan = planned(1, 0.25);
+ let plain = camera_layer_cb(&plan, None, visible, tex, render, &base);
+ assert_eq!(plain.dst, plan.dst);
+ assert_eq!(plain.dst_prev, plan.dst);
+ assert_eq!(plain.quad_px, [480.0, 540.0]);
+ assert_eq!(plain.radius_px, 0.2 * 480.0);
+ assert_eq!(plain.layer_fx, [0.75, 0.0, 0.0, 0.0]);
+ assert_eq!(plain.persp, [[0.0; 4]; 3]);
+ assert_eq!(plain.src_prev, plain.src);
+ assert_eq!(plain.mb[0], 1.0);
+ assert_eq!(plain.fx, base.fx);
+
+ // A crop moves the source rect; with a homography as well, the crop is ignored.
+ let crop = SceneCrop { x: 0.5, y: 0.0, width: 0.5, height: 1.0 };
+ let cropped = extra_camera(1);
+ let cropped = crate::scene::SceneCamera { crop: Some(crop), ..cropped };
+ let with_crop = camera_layer_cb(&plan, Some(&cropped), visible, tex, render, &base);
+ assert_ne!(with_crop.src, plain.src);
+ let h = [-1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
+ let corrected = crate::scene::SceneCamera { homography: Some(h), ..cropped.clone() };
+ let warped = camera_layer_cb(&plan, Some(&corrected), visible, tex, render, &base);
+ assert_eq!(warped.layer_fx[1], 1.0);
+ assert_eq!(warped.layer_fx[0], 0.75);
+ let rows = [[-1.0, 0.0, 1.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
+ assert_eq!(warped.persp, rows);
+ assert_eq!(warped.src, plain.src);
+
+ // Mirror swaps the u bounds only; 180° swaps both, and mirror on top of it un-swaps u.
+ let mirrored = crate::scene::SceneCamera { mirror: Some(true), ..extra_camera(1) };
+ let m = camera_layer_cb(&plan, Some(&mirrored), visible, tex, render, &base).src;
+ assert_eq!(m, [plain.src[2], plain.src[1], plain.src[0], plain.src[3]]);
+ let turned = crate::scene::SceneCamera { rotation: 180, ..extra_camera(1) };
+ let t = camera_layer_cb(&plan, Some(&turned), visible, tex, render, &base).src;
+ assert_eq!(t, [plain.src[2], plain.src[3], plain.src[0], plain.src[1]]);
+ let both = crate::scene::SceneCamera { mirror: Some(true), ..turned.clone() };
+ let b = camera_layer_cb(&plan, Some(&both), visible, tex, render, &base).src;
+ assert_eq!(b, [plain.src[0], plain.src[3], plain.src[2], plain.src[1]]);
+ }
+
+ /// Camera 0's default layer, planned outside every layout region at its own rect and fully
+ /// opaque, keeps its motion trail; moved or faded, it draws without one (R8).
+ #[test]
+ fn camera_0s_untouched_layer_keeps_its_trail() {
+ let render = [1920.0, 1080.0];
+ let base = LayerCB {
+ dst: [0.7, 0.7, 0.2, 0.2],
+ dst_prev: [0.65, 0.7, 0.2, 0.2],
+ mb: [8.0, 0.5, 1.0, 0.0],
+ ..Default::default()
+ };
+ let plan = crate::camera_layers::CameraLayerPlan {
+ camera: 0,
+ dst: base.dst,
+ radius_frac: 0.2,
+ shape: 0,
+ opacity: 1.0,
+ fills_frame: false,
+ };
+ let kept = camera_layer_cb(&plan, None, [64.0; 2], [64.0; 2], render, &base);
+ assert_eq!(kept.dst_prev, base.dst_prev);
+ assert_eq!(kept.mb, base.mb);
+
+ let moved = crate::camera_layers::CameraLayerPlan { dst: [0.1, 0.1, 0.2, 0.2], ..plan };
+ let m = camera_layer_cb(&moved, None, [64.0; 2], [64.0; 2], render, &base);
+ assert_eq!(m.dst_prev, moved.dst);
+ assert_eq!(m.mb, [1.0, 0.0, 1.0, 0.0]);
+
+ let faded = crate::camera_layers::CameraLayerPlan { opacity: 0.5, ..plan };
+ let f = camera_layer_cb(&faded, None, [64.0; 2], [64.0; 2], render, &base);
+ assert_eq!(f.dst_prev, faded.dst);
+ assert_eq!(f.mb, [1.0, 0.0, 1.0, 0.0]);
+ }
+
+ /// A corrected camera in a box of another ratio is cover-cropped, not stretched: the box's
+ /// local uv lands on a centred window of the corrected picture with the box's own ratio.
+ #[test]
+ fn a_corrected_camera_is_cover_fitted_into_its_box() {
+ let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
+ let cam = crate::scene::SceneCamera {
+ homography: Some(identity),
+ aspect: Some(16.0 / 9.0),
+ ..extra_camera(2)
+ };
+ let row = |cb: &LayerCB, r: usize| [cb.persp[r][0], cb.persp[r][1], cb.persp[r][2]];
+ let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
+
+ // A box narrower than 16:9 (1752x1080, a 1.62 export): the full height, a centred
+ // slice of the width with the box's ratio.
+ let narrow = LayerCB { quad_px: [1752.0, 1080.0], ..Default::default() };
+ let cb = with_camera_homography(narrow, Some(&cam));
+ let du = (1752.0 / 1080.0) / (16.0 / 9.0);
+ assert!(near(row(&cb, 0), [du, 0.0, (1.0 - du) * 0.5]), "{:?}", cb.persp);
+ assert!(near(row(&cb, 1), [0.0, 1.0, 0.0]), "{:?}", cb.persp);
+ assert!(near(row(&cb, 2), [0.0, 0.0, 1.0]), "{:?}", cb.persp);
+
+ // A wider box (a 16:9 picture in a 21:9 box): full width, a centred band of height.
+ let wide = LayerCB { quad_px: [2100.0, 900.0], ..Default::default() };
+ let cb = with_camera_homography(wide, Some(&cam));
+ let dv = (16.0 / 9.0) / (2100.0 / 900.0);
+ assert!(near(row(&cb, 0), [1.0, 0.0, 0.0]), "{:?}", cb.persp);
+ assert!(near(row(&cb, 1), [0.0, dv, (1.0 - dv) * 0.5]), "{:?}", cb.persp);
+
+ // The box the template gives a PiP of this camera has its aspect: nothing is cropped.
+ let pip = LayerCB { quad_px: [422.4, 237.6], ..Default::default() };
+ let cb = with_camera_homography(pip, Some(&cam));
+ assert!(near(row(&cb, 0), [1.0, 0.0, 0.0]) && near(row(&cb, 1), [0.0, 1.0, 0.0]));
+
+ // A real (projective) matrix is composed on the right: H * C, so H's third row picks
+ // up the window too.
+ let h = [0.8, 0.1, 0.05, -0.02, 0.9, 0.04, 0.1, -0.2, 1.0];
+ let cam = crate::scene::SceneCamera { homography: Some(h), ..cam };
+ let cb = with_camera_homography(narrow, Some(&cam));
+ let u0 = (1.0 - du) * 0.5;
+ assert!(near(row(&cb, 2), [0.1 * du, -0.2, 0.1 * u0 + 1.0]), "{:?}", cb.persp);
+ assert_eq!(cb.layer_fx[1], 1.0);
+ }
+
+ /// Without an aspect, or with a degenerate box, the whole corrected picture is shown.
+ #[test]
+ fn the_cover_window_falls_back_to_the_whole_picture() {
+ let whole = [0.0, 0.0, 1.0, 1.0];
+ assert_eq!(corrected_cover_window(None, 1.5), whole);
+ assert_eq!(corrected_cover_window(Some(0.0), 1.5), whole);
+ assert_eq!(corrected_cover_window(Some(1.5), f32::NAN), whole);
+ assert_eq!(corrected_cover_window(Some(1.5), f32::INFINITY), whole);
+ assert_eq!(corrected_cover_window(Some(1.5), 0.0), whole);
+ assert_eq!(corrected_cover_window(Some(1.5), 1.5), whole);
+ }
+
+ /// A homography with a NaN or an infinity in it counts as none.
+ #[test]
+ fn a_non_finite_homography_is_ignored() {
+ let mut h = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
+ let ok = crate::scene::SceneCamera { homography: Some(h), ..extra_camera(1) };
+ assert_eq!(camera_homography(Some(&ok)), Some(h));
+ for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
+ h[7] = bad;
+ let cam = crate::scene::SceneCamera { homography: Some(h), ..extra_camera(1) };
+ assert_eq!(camera_homography(Some(&cam)), None);
+ let cb = with_camera_homography(LayerCB::default(), Some(&cam));
+ assert_eq!(cb.layer_fx, [0.0; 4]);
+ assert_eq!(cb.persp, [[0.0; 4]; 3]);
+ }
+ }
+
+ /// Camera 0's settings give back exactly today's source rect: layout crop, project mirror
+ /// and desk turn, through `camera_source_rect`.
+ #[test]
+ fn camera_0_settings_reproduce_todays_source_rect() {
+ let crop = SceneCrop { x: 0.1, y: 0.2, width: 0.6, height: 0.7 };
+ let (visible, tex, box_ar) = ([1280.0, 720.0], [1280.0, 736.0], 1.3);
+ for (flip_u, flip_v, full_frame) in [
+ (false, false, false),
+ (true, false, false),
+ (false, true, false),
+ (true, true, false),
+ (false, true, true),
+ ] {
+ let mut g = plan_frame(&golden_input(&golden_scene(), &crate::config::all()[0]));
+ g.webcam = WebcamOrientation { flip_u, flip_v, full_frame };
+ let mut scene = golden_scene();
+ scene.layout.webcam_crop = Some(crop);
+ let [cu0, cv0, cu1, cv1] = webcam_source_rect(
+ visible,
+ tex,
+ if full_frame { None } else { Some(crop) },
+ box_ar,
+ );
+ let (u0, u1) = if flip_u { (cu1, cu0) } else { (cu0, cu1) };
+ let (v0, v1) = if flip_v { (cv1, cv0) } else { (cv0, cv1) };
+ let cam0 = g.camera0_settings(Some(&scene));
+ assert!(cam0.homography.is_none());
+ assert_eq!(camera_source_rect(Some(&cam0), visible, tex, box_ar), [u0, v0, u1, v1]);
+ }
+ }
+
+ /// Camera 0 with a perspective is cover-fitted into a box of another ratio, on both paths:
+ /// its default draw in a Full Camera section (no layout region, the box fills the frame)
+ /// and a planned frame-filling layer. Its PiP box, which has the corrected aspect, shows
+ /// the whole corrected picture.
+ #[test]
+ fn camera_0_with_a_perspective_is_cover_fitted_on_both_paths() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let identity = "[1,0,0,0,1,0,0,0,1]";
+ let aspect = 4.0_f32 / 3.0;
+ let cameras = format!(r#""cameras":[{{"index":0,"homography":{identity},"aspect":{aspect}}}]"#);
+ let zoom = r#""zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#;
+ let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
+ let render = [1170.0_f32, 658.0];
+ let box_ar = render[0] / render[1];
+ // A 4:3 picture in a wider box: the full width, a centred band of the box's ratio.
+ let dv = aspect / box_ar;
+ let check = |cb: &LayerCB| {
+ assert_eq!(cb.layer_fx[1], 1.0);
+ assert!(near(cb.persp[0][0], 1.0) && near(cb.persp[0][2], 0.0), "{:?}", cb.persp);
+ assert!(near(cb.persp[1][1], dv), "{:?}", cb.persp);
+ assert!(near(cb.persp[1][2], (1.0 - dv) * 0.5), "{:?}", cb.persp);
+ };
+
+ // No layout region, inside a Full Camera section: the default box fills the frame.
+ let json = zoomed_golden_scene_json().replace(
+ zoom,
+ &format!(
+ r#""zoomRegions":[],{cameras},"cameraFullscreenRegions":[{{"clipIndex":0,"startSec":0.0,"endSec":9.0}}]"#
+ ),
+ );
+ let scene = Scene::from_json(&json).expect("scene");
+ let g = plan_frame(&FrameGeometryInput {
+ timeline_t_override: Some(4.0),
+ ..golden_input(&scene, &cfg)
+ });
+ assert!(g.camera_layers.is_empty());
+ assert!(near(g.w_px[0], render[0]) && near(g.w_px[1], render[1]), "{:?}", g.w_px);
+ let cam0 = g.camera0_settings(Some(&scene));
+ assert_eq!(cam0.aspect, Some(aspect));
+ check(&with_camera_homography(
+ g.webcam_video_cb([0.0, 0.0, 1.0, 1.0], [1.0, 1.0], 0.0, 0.0),
+ Some(&cam0),
+ ));
+
+ // A planned layer that fills the frame.
+ let json = zoomed_golden_scene_json().replace(
+ zoom,
+ &format!(
+ r#""zoomRegions":[],{cameras},"cameraLayoutRegions":[{{"clipIndex":0,"startSec":0.0,"endSec":4.0,"layers":[
+ {{"camera":0,"rect":{{"x":0,"y":0,"width":1,"height":1}},"fillsFrame":true}}]}}]"#
+ ),
+ );
+ let scene = Scene::from_json(&json).expect("scene");
+ let g = plan_frame(&golden_input(&scene, &cfg));
+ assert_eq!(g.camera_layers.len(), 1);
+ let cam0 = g.camera0_settings(Some(&scene));
+ let base = g.webcam_video_cb([0.0; 4], [1.0, 1.0], 0.0, 0.0);
+ let plan = g.camera_layers[0];
+ check(&camera_layer_cb(&plan, Some(&cam0), [1280.0, 720.0], [1280.0, 720.0], render, &base));
+
+ // The PiP box the app gives camera 0 has the corrected aspect: nothing is cropped.
+ let pip = LayerCB { quad_px: [400.0, 300.0], ..Default::default() };
+ let cb = with_camera_homography(pip, Some(&cam0));
+ assert!(near(cb.persp[0][0], 1.0) && near(cb.persp[1][1], 1.0), "{:?}", cb.persp);
+ }
+
+ /// The shadow of a planned layer fades with it, and a frame-filling layer has none.
+ #[test]
+ fn a_planned_layers_shadow_fades_with_it() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let g = plan_frame(&golden_input(&golden_scene(), &cfg));
+ let render = [1920.0, 1080.0];
+ let plan = planned(2, 0.4);
+ let base = extra_camera_base_cb([1.0; 2]);
+ let video = camera_layer_cb(&plan, None, [64.0; 2], [64.0; 2], render, &base);
+ let shadow = g.camera_layer_shadow(&plan, &video, render, true, false).expect("shadow");
+ assert_eq!(shadow.mode, 2.0);
+ assert_eq!(shadow.layer_fx[0], video.layer_fx[0]);
+ assert_eq!(shadow.color[3], WEBCAM_SHADOW_OPACITY);
+ assert!(g.camera_layer_shadow(&plan, &video, render, false, false).is_none());
+ let full = crate::camera_layers::CameraLayerPlan { fills_frame: true, ..plan };
+ assert!(g.camera_layer_shadow(&full, &video, render, true, false).is_none());
+ // Cutout removes camera 0's bubble, not the others'.
+ assert!(g.camera_layer_shadow(&plan, &video, render, true, true).is_some());
+ let cam0 = crate::camera_layers::CameraLayerPlan { camera: 0, ..plan };
+ assert!(g.camera_layer_shadow(&cam0, &video, render, true, true).is_none());
+ }
+
+ /// A turned Full Camera section from 1 s to 9 s: the plan carries the cover strength, full in
+ /// the hold after the start and zero in the steady part.
+ #[test]
+ fn the_frame_plan_carries_the_cover() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let json = zoomed_golden_scene_json().replace(
+ r#""zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#,
+ r#""zoomRegions":[],"cameraFullscreenRegions":[{"clipIndex":0,"startSec":1.0,"endSec":9.0,"rotation":180,"fullFrame":true}]"#,
+ );
+ let scene = Scene::from_json(&json).expect("scene");
+ let cover_at = |t: f32| {
+ plan_frame(&FrameGeometryInput { timeline_t_override: Some(t), ..golden_input(&scene, &cfg) })
+ .webcam_cover
+ };
+ assert_eq!(cover_at(1.5), 1.0);
+ assert_eq!(cover_at(5.0), 0.0);
+ }
+
+ /// A scene without layout regions plans no camera layers, and camera 0 lands exactly where
+ /// it did: the new (empty) keys change nothing.
+ #[test]
+ fn without_layout_regions_the_plan_has_no_camera_layers() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let before = plan_frame(&golden_input(&golden_scene(), &cfg));
+ let json = zoomed_golden_scene_json().replace(
+ r#""zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#,
+ r#""zoomRegions":[],"cameras":[],"cameraLayoutRegions":[]"#,
+ );
+ let scene = Scene::from_json(&json).expect("scene");
+ let after = plan_frame(&golden_input(&scene, &cfg));
+ assert!(before.camera_layers.is_empty());
+ assert!(after.camera_layers.is_empty());
+ assert_eq!(after.w_dst, before.w_dst);
+ assert_eq!(after.w_dst_prev, before.w_dst_prev);
+ assert_eq!(after.w_px, before.w_px);
+ assert_eq!(after.w_radius, before.w_radius);
+ assert_eq!(after.shape_fade, before.shape_fade);
+ assert_eq!(after.webcam, before.webcam);
+ assert_eq!(after.webcam_cover, before.webcam_cover);
+ }
+
+ /// Inside a layout region camera 0 is one of the planned layers, at the region's rect.
+ #[test]
+ fn with_a_layout_region_camera_0_comes_from_the_plan() {
+ let cfg = crate::config::all().pop().expect("cfg");
+ let json = zoomed_golden_scene_json().replace(
+ r#""zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#,
+ r#""zoomRegions":[],"cameraLayoutRegions":[{"clipIndex":0,"startSec":0.0,"endSec":4.0,"layers":[
+ {"camera":0,"rect":{"x":0.1,"y":0.2,"width":0.3,"height":0.4},"radiusFrac":0.1,"shape":"rectangle"}]}]"#,
+ );
+ let scene = Scene::from_json(&json).expect("scene");
+ // `golden_input` samples t = 1.5 s: past the lead-in, well before the lead-out.
+ let g = plan_frame(&golden_input(&scene, &cfg));
+ assert_eq!(g.camera_layers.len(), 1);
+ let cam0 = g.camera_layers[0];
+ assert_eq!(cam0.camera, 0);
+ assert_eq!(cam0.dst, [0.1, 0.2, 0.3, 0.4]);
+ assert_eq!(cam0.opacity, 1.0);
+ assert_eq!(cam0.shape, webcam_shape_code("rectangle"));
+ assert!(!cam0.fills_frame);
+ // Outside the region camera 0 is the default PiP, where `w_dst` puts it.
+ let outside = plan_frame(&FrameGeometryInput {
+ timeline_t_override: Some(6.0),
+ ..golden_input(&scene, &cfg)
+ });
+ assert_eq!(outside.camera_layers.len(), 1);
+ assert_eq!(outside.camera_layers[0].dst, outside.w_dst);
+ let r = outside.w_radius / outside.w_px[0].min(outside.w_px[1]).max(1.0);
+ assert_eq!(outside.camera_layers[0].radius_frac, r);
+ }
+
+ /// The desk label's opacity at the compositor caller boundary, against the cover of the same
+ /// frame plan: equal at every sampled frame, and the label (spanned over its whole section by
+ /// the app) is on screen wherever the cover is not 0. Three sections: the review's 2.6 s one
+ /// at 1×, where the label's old fade (from its own 1.15 s window) was half gone while the
+ /// cover still held 1; a long one; and a section inside a 2× speed region, whose cover runs
+ /// on the screen clock.
+ #[test]
+ fn the_desk_label_fades_with_the_cover() {
+ use crate::text_anim::{annotation_text_state, DESK_COVER_ANIMATION};
+ let cfg = crate::config::all().pop().expect("cfg");
+ let zoom = r#""zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#;
+ let section = |start: f32, end: f32, speed: Option<&str>| {
+ let regions = format!(
+ r#""zoomRegions":[],"cameraFullscreenRegions":[{{"clipIndex":0,"startSec":{start},"endSec":{end},"rotation":180,"fullFrame":true}}]{}"#,
+ speed.map(|s| format!(",{s}")).unwrap_or_default()
+ );
+ Scene::from_json(&zoomed_golden_scene_json().replace(zoom, ®ions)).expect("scene")
+ };
+ // (label, cover) at source time `t` of a label spanning [start, end].
+ let sample = |scene: &Scene, start: f32, end: f32, t: f32| {
+ let g = plan_frame(&FrameGeometryInput {
+ timeline_t_override: Some(t),
+ ..golden_input(scene, &cfg)
+ });
+ let label = annotation_text_state(
+ Some(DESK_COVER_ANIMATION),
+ (t - start) * 1000.0,
+ (end - start) * 1000.0,
+ g.webcam_cover,
+ );
+ (label.opacity, g.webcam_cover)
+ };
+ let speed = r#""speedRegions":[{"clipIndex":0,"startSec":1.0,"endSec":9.0,"speed":2.0}]"#;
+ let cases = [
+ ("2.6 s at 1x", section(1.0, 3.6, None), 1.0f32, 3.6f32),
+ ("long at 1x", section(1.0, 9.0, None), 1.0, 9.0),
+ ("2.6 s on screen inside 2x", section(2.0, 7.2, Some(speed)), 2.0, 7.2),
+ ];
+ for (name, scene, start, end) in &cases {
+ let mut t = start - 0.1;
+ while t < end + 0.1 {
+ let (label, cover) = sample(scene, *start, *end, t);
+ assert_eq!(label, cover, "{name}: t = {t}");
+ if cover > 0.0 {
+ assert!(t >= *start && t < *end, "{name}: cover {cover} outside the label at {t}");
+ }
+ t += 1.0 / 120.0;
+ }
+ }
+ // The review's frames: the cover still holds 1 at 0.9 s and 1.0 s (the old label was at
+ // ~0.5 and below). The 0.28 s left between the holds is two 0.14 s fades meeting at
+ // +1.1575 s, where label and cover touch 0 together before rising into the end hold.
+ let (_, short, s0, s1) = &cases[0];
+ for at in [0.5, 0.9, 1.0] {
+ assert_eq!(sample(short, *s0, *s1, s0 + at), (1.0, 1.0), "at +{at} s");
+ }
+ let (mid, _) = sample(short, *s0, *s1, s0 + 1.09);
+ assert!(mid > 0.0 && mid < 1.0, "mid-fade {mid}");
+ let (low, low_cover) = sample(short, *s0, *s1, s0 + 1.1575);
+ assert!(low < 1e-3 && low == low_cover, "junction {low} vs {low_cover}");
+ assert_eq!(sample(short, *s0, *s1, s1 - 1.25), (1.0, 1.0), "end hold");
+ // Inside 2x the same 2.6 s of screen time takes 5.2 s of source: every screen instant
+ // matches the 1x section's, which the source-time label could not do.
+ let (_, fast, f0, f1) = &cases[2];
+ for k in 0..=26 {
+ let screen = k as f32 * 0.1;
+ let slow = sample(short, *s0, *s1, s0 + screen);
+ let quick = sample(fast, *f0, *f1, f0 + 2.0 * screen);
+ assert!((slow.0 - quick.0).abs() < 1e-3, "screen +{screen} s: {slow:?} vs {quick:?}");
+ }
+ }
+
+ /// A turned Full Camera section that meets a layout region: the cover follows the seam rule
+ /// (no hold before the layout region, only its own fade), and the label, spanned over the
+ /// whole section by the app, follows the cover there frame for frame without any seam
+ /// logic of its own.
+ #[test]
+ fn the_desk_label_follows_the_cover_across_a_seam_with_a_layout_region() {
+ use crate::regions::DESK_COVER_FADE_S;
+ use crate::text_anim::{annotation_text_state, DESK_COVER_ANIMATION};
+ let cfg = crate::config::all().pop().expect("cfg");
+ let zoom = r#""zoomRegions":[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#;
+ let full = r#""zoomRegions":[],"cameraFullscreenRegions":[{"clipIndex":0,"startSec":1.0,"endSec":6.0,"rotation":180,"fullFrame":true}]"#;
+ let layout = r#","cameraLayoutRegions":[{"clipIndex":0,"startSec":6.0,"endSec":9.0,"layers":[
+ {"camera":0,"rect":{"x":0.1,"y":0.2,"width":0.3,"height":0.4},"radiusFrac":0.1,"shape":"rectangle"}]}]"#;
+ let json = zoomed_golden_scene_json();
+ let seam =
+ Scene::from_json(&json.replace(zoom, &format!("{full}{layout}"))).expect("scene");
+ let lone = Scene::from_json(&json.replace(zoom, full)).expect("scene");
+ let (start, end) = (1.0f32, 6.0f32);
+ let sample = |scene: &Scene, t: f32| {
+ let g = plan_frame(&FrameGeometryInput {
+ timeline_t_override: Some(t),
+ ..golden_input(scene, &cfg)
+ });
+ let label = annotation_text_state(
+ Some(DESK_COVER_ANIMATION),
+ (t - start) * 1000.0,
+ (end - start) * 1000.0,
+ g.webcam_cover,
+ );
+ (label.opacity, g.webcam_cover)
+ };
+ let mut t = start - 0.1;
+ while t < 9.1 {
+ let (label, cover) = sample(&seam, t);
+ assert_eq!(label, cover, "t = {t}");
+ if cover > 0.0 {
+ assert!(t >= start && t < end, "cover {cover} outside the label at {t}");
+ }
+ t += 1.0 / 120.0;
+ }
+ // Before the seam the cover (and so the label) is only its fade: half way up at
+ // end - fade/2 and sharp just before the fade, where a lone section still holds 1.
+ let (mid, _) = sample(&seam, end - DESK_COVER_FADE_S / 2.0);
+ assert!((mid - 0.5).abs() < 1e-3, "half way up at the seam: {mid}");
+ let before_fade = end - DESK_COVER_FADE_S - 0.05;
+ assert_eq!(sample(&seam, before_fade), (0.0, 0.0), "sharp before the seam's fade");
+ assert_eq!(sample(&lone, before_fade), (1.0, 1.0), "a lone section still holds");
+ assert!(sample(&seam, end - 0.001).0 > 0.99, "covered at the seam");
+ }
+
/// Le pivot doit rester collé à `center` quand le sprite grandit — c'est exactement ce qui
/// était cassé (ancrage centré en dur : la pointe s'éloignait proportionnellement à la
/// taille). On dessine la même flèche à deux tailles et on vérifie que le point désigné
@@ -7444,6 +8573,9 @@ mod tests {
w_px: [0.0, 0.0],
w_radius: 0.0,
shape_fade: 0.0,
+ webcam: WebcamOrientation::default(),
+ webcam_cover: 0.0,
+ camera_layers: Vec::new(),
window_frame: None,
screen_mask: None,
}
@@ -8508,4 +9640,117 @@ mod tests {
println!("left ×2 : {:.3} côté proche, {:.3} côté lointain", near / at_focus, far / at_focus);
assert!(near > 1.04 * at_focus && far < 0.96 * at_focus, "{near} {at_focus} {far}");
}
+
+ use crate::scene::SceneCameraFullscreenRegion;
+
+ fn cam_region(rotation: u16, mirror: Option) -> SceneCameraFullscreenRegion {
+ SceneCameraFullscreenRegion {
+ clip_index: None,
+ start_sec: 0.0,
+ end_sec: 1.0,
+ rotation,
+ mirror,
+ full_frame: rotation == 180,
+ }
+ }
+
+ #[test]
+ fn without_a_region_the_webcam_is_laid_as_before() {
+ for m in [false, true] {
+ assert_eq!(
+ webcam_orientation(None, m),
+ WebcamOrientation { flip_u: m, flip_v: false, full_frame: false }
+ );
+ }
+ }
+
+ /// 180° is both axes swapped; a mirror on top cancels the horizontal one.
+ #[test]
+ fn a_turned_region_swaps_both_axes_and_a_mirror_cancels_one() {
+ let turned = cam_region(180, Some(false));
+ assert_eq!(
+ webcam_orientation(Some(&turned), true),
+ WebcamOrientation { flip_u: true, flip_v: true, full_frame: true }
+ );
+ let turned_mirrored = cam_region(180, Some(true));
+ assert_eq!(
+ webcam_orientation(Some(&turned_mirrored), false),
+ WebcamOrientation { flip_u: false, flip_v: true, full_frame: true }
+ );
+ }
+
+ #[test]
+ fn a_plain_region_keeps_the_layout_mirror_and_an_unknown_rotation_is_none() {
+ assert!(webcam_orientation(Some(&cam_region(0, None)), true).flip_u);
+ let odd = cam_region(90, None);
+ assert_eq!(
+ webcam_orientation(Some(&odd), false),
+ WebcamOrientation { flip_u: false, flip_v: false, full_frame: false }
+ );
+ }
+
+ /// `layer.wgsl` is only compiled on Linux, in CI, where a name or syntax error breaks every
+ /// draw. Parse and validate it on every host through wgpu's own naga, and pin the desk-view
+ /// cover: the fragment entry point must call the radius-taking blur kernel, and the kernel
+ /// must keep its taps inside the picture's valid area.
+ ///
+ /// The file does not declare `LAYER_MODELS`: `compositor_linux::layer_source` prefixes it,
+ /// once per pipeline. Both variants are checked here the same way, the one without the 3D
+ /// models first, since that is the pipeline the webcam (mode 0) is drawn with.
+ #[test]
+ fn layer_wgsl_validates_and_its_fragment_applies_the_desk_view_cover() {
+ use wgpu::naga;
+ fn calls(block: &naga::Block, target: naga::Handle) -> usize {
+ block
+ .iter()
+ .map(|st| match st {
+ naga::Statement::Call { function, .. } => usize::from(*function == target),
+ naga::Statement::Block(b) => calls(b, target),
+ naga::Statement::If { accept, reject, .. } => calls(accept, target) + calls(reject, target),
+ naga::Statement::Loop { body, continuing, .. } => calls(body, target) + calls(continuing, target),
+ naga::Statement::Switch { cases, .. } => cases.iter().map(|c| calls(&c.body, target)).sum(),
+ _ => 0,
+ })
+ .sum()
+ }
+
+ for models in [false, true] {
+ // The same prefix as `compositor_linux::layer_source`, which only builds on Linux.
+ let source =
+ format!("const LAYER_MODELS: bool = {models};\n{}", include_str!("vk_shaders/layer.wgsl"));
+ let module = naga::front::wgsl::parse_str(&source)
+ .unwrap_or_else(|e| panic!("layer.wgsl (LAYER_MODELS = {models}) parses: {e:?}"));
+ naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all())
+ .validate(&module)
+ .unwrap_or_else(|e| panic!("layer.wgsl (LAYER_MODELS = {models}) validates: {e:?}"));
+ let (kernel, f) = module
+ .functions
+ .iter()
+ .find(|(_, f)| f.name.as_deref() == Some("blur_webcam_radius"))
+ .expect("blur_webcam_radius exists");
+ assert_eq!(f.arguments.len(), 5, "uv, max_r_px, qpx, local_px, valid");
+ // The taps are clamped to the valid part of an aligned decoder texture, half a texel
+ // in: the kernel reads the texture size to know what half a texel is.
+ assert!(
+ f.expressions.iter().any(|(_, e)| matches!(
+ e,
+ naga::Expression::ImageQuery { query: naga::ImageQuery::Size { .. }, .. }
+ )),
+ "blur_webcam_radius reads the texture size for its half-texel clamp"
+ );
+ // `fs_main` only applies the layer's transparency to `fs_layer`, which draws it.
+ let fs = module.entry_points.iter().find(|e| e.name == "fs_main").expect("fs_main");
+ let (body, f) = module
+ .functions
+ .iter()
+ .find(|(_, f)| f.name.as_deref() == Some("fs_layer"))
+ .expect("fs_layer exists");
+ assert_eq!(calls(&fs.function.body, body), 1, "fs_main draws through fs_layer");
+ assert_eq!(
+ calls(&f.body, kernel),
+ 1,
+ "fs_layer blurs the covered camera once (LAYER_MODELS = {models})"
+ );
+ }
+ }
}
diff --git a/crates/compositor/src/gif_export.rs b/crates/compositor/src/gif_export.rs
index 8215704c2..7ab0c1b6c 100644
--- a/crates/compositor/src/gif_export.rs
+++ b/crates/compositor/src/gif_export.rs
@@ -256,6 +256,7 @@ fn export_gif_inner(
let mut screen_decs: HashMap = HashMap::new();
let mut webcam_decs: HashMap = HashMap::new();
+ let mut extra_decs: HashMap = HashMap::new();
screen_decs.insert(clips[0].screen.clone(), unsafe {
Decoder::open_for_export(&clips[0].screen, gpu)?
});
@@ -270,6 +271,7 @@ fn export_gif_inner(
&scene,
&mut screen_decs,
&mut webcam_decs,
+ &mut extra_decs,
&mut |frame_index| {
control.check()?;
// CPU readback of the staged RT (RGBA8 tightly-packed,
diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs
index e26480cc6..67c233e4b 100644
--- a/crates/compositor/src/lib.rs
+++ b/crates/compositor/src/lib.rs
@@ -30,6 +30,7 @@
pub mod audio;
pub mod audio_jobs;
pub mod camera;
+pub mod camera_layers;
pub mod config;
pub mod cursor;
pub mod cursor_sdf;
@@ -43,6 +44,7 @@ pub mod regions;
// cibles) et le shim C. Seul Linux l'appelle aujourd'hui, parce que c'est la
// seule plateforme dont la capture passe par `MediaRecorder`, mais rien dedans
// n'est spécifique à Linux.
+pub mod prism_mesh;
pub mod remux;
pub mod scene;
pub mod sculpt;
@@ -50,9 +52,12 @@ pub mod sculpt;
// `segmentation` ses deux entrées échouent proprement, ce qui garde le reste du crate
// indépendant du choix de packaging d'ONNX Runtime.
pub mod segmentation;
+// Livraison de la preview par textures partagées (Windows) ; la tenue des cases est portable.
+pub mod shared_frames;
pub mod text_anim;
pub mod text_fonts;
pub mod text_plate;
+pub(crate) mod extra_cameras;
pub(crate) mod timeline_walk;
// GPU backend : Windows → d3d_windows, macOS → d3d_macos. Ré-exporté sous le nom `d3d`
diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs
index ebcb46388..d8e2c4e87 100644
--- a/crates/compositor/src/live.rs
+++ b/crates/compositor/src/live.rs
@@ -23,13 +23,21 @@
//! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`).
use crate::compositor::{Compositor, LiveParams};
+use crate::ffi::AVFrame;
use crate::regions::{speed_at, ProgrammeClock};
-use crate::scene::Scene;
+use crate::scene::{Scene, SceneCameraLayoutRegion, SceneClipCamera};
use crate::config::{self, Cfg};
use crate::cursor::CursorTrack;
-use crate::d3d::Gpu;
+use crate::d3d::{Backend, Gpu};
use crate::frame_geometry::webcam_is_real;
use crate::pipeline::Decoder;
+#[cfg(windows)]
+use crate::shared_frames::SharedRing;
+use crate::shared_frames::{SharedFrame, SlotBook};
+use crate::extra_cameras::{
+ camera_source_time, cameras_in_regions, extra_camera_active, extra_camera_keys, extra_frame_list,
+ has_camera_file, opened_or_skipped, seek_extra_camera, step_extra_camera, ExtraCameraKeys,
+};
use crate::timeline_walk::{frame_step, FrameStep, NextFrameTime};
use anyhow::Result;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -49,10 +57,6 @@ fn parse_hex_color(s: &str) -> Option<[f32; 4]> {
Some([r, g, b, 1.0])
}
-fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 {
- (screen_source_time_sec - webcam_offset_sec).max(0.0)
-}
-
/// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat
/// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch`
/// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus
@@ -69,6 +73,9 @@ struct PrefetchedClip {
/// des paires ouvertes plusieurs bascules plus tôt. Cf. `open_webcam_or_stand_in`.
webcam_decoder_is_real: bool,
webcam_offset_sec: f64,
+ /// The clip's extra cameras (index k-1 = camera k) and what they were opened for.
+ extra: Vec>,
+ extra_keys: ExtraCameraKeys,
idx: u32,
/// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs :
/// sans ça, la bascule à la frontière restait synchrone sur CE point précis (lecture +
@@ -106,9 +113,13 @@ struct PrefetchedClip {
/// `should_draw_webcam`).
///
/// ponytail: on garde le remplaçant plutôt que de passer `wdec` en `Option`, ce qui
-/// toucherait 22 sites dont le pool de décodeurs et la boucle de composition `unsafe`. À faire
-/// si quelqu'un mesure que le décodeur inutile coûte (VRAM des pools D3D11VA, une ouverture
-/// par clip) — l'avertissement ci-dessous dit enfin à quelle fréquence le cas visible arrive.
+/// toucherait 22 sites dont le pool de décodeurs et la boucle de composition `unsafe`. Il est
+/// ouvert mais n'est plus jamais avancé ni recherché : la composition reçoit la frame écran à
+/// sa place (`Player::webcam_frame`), l'image même qu'il aurait décodée. Le décoder à côté de
+/// l'écran coûtait la moitié du débit de décodage, sans caméra, c'est-à-dire le cas courant :
+/// une source 4K lue à 2× plafonnait à ~46 frames/s pour 97 que le décodeur seul tient, et la
+/// preview prenait des secondes de retard dans les régions de vitesse. Reste à faire si la
+/// VRAM des pools D3D11VA ou l'ouverture par clip se mesurent.
unsafe fn open_webcam_or_stand_in(
screen_path: &str,
webcam_path: &str,
@@ -142,27 +153,139 @@ fn should_draw_webcam(webcam_path: &str, screen_path: &str, decoder_is_real: boo
webcam_is_real(webcam_path, screen_path) && decoder_is_real
}
+/// An extra camera (k >= 1) of the active clip, decoded only near the layout regions that show
+/// it (`extra_camera_active`).
+struct ExtraCamera {
+ dec: Decoder,
+ offset_sec: f64,
+ /// The last step or seek left a frame to draw. Read by `Player::recompose`.
+ shown: bool,
+ /// Camera source time past which the file has no frame left (`step_extra_camera`).
+ ended_at: Option,
+}
+
+impl ExtraCamera {
+ /// `step_extra_camera` toward `target` (camera source time).
+ unsafe fn step_to(&mut self, target: f64) -> Result<*const AVFrame> {
+ let frame = step_extra_camera(&mut self.dec, &mut self.ended_at, target)?;
+ self.shown = !frame.is_null();
+ Ok(frame)
+ }
+
+ /// `seek_extra_camera` to `target` (camera source time).
+ unsafe fn seek(&mut self, target: f64) -> Result<*const AVFrame> {
+ let frame = seek_extra_camera(&mut self.dec, &mut self.ended_at, target)?;
+ self.shown = !frame.is_null();
+ Ok(frame)
+ }
+}
+
+/// The extra cameras clip `clip_index` opens: those its own layout regions
+/// (`Scene::for_clip_window`) draw and that the clip has a file for.
+fn extra_cameras_for_clip(scene: &Scene, clip_index: usize) -> Vec {
+ let Some(clip) = scene.clips.get(clip_index) else {
+ return Vec::new();
+ };
+ // The common case — no layout region anywhere — skips the windowing copy of the scene.
+ if scene.camera_layout_regions.is_empty() || clip.additional_cameras.is_empty() {
+ return Vec::new();
+ }
+ cameras_in_regions(&scene_for_clip(scene, clip_index).camera_layout_regions)
+ .into_iter()
+ .filter(|&k| has_camera_file(clip.additional_cameras.get(k - 1)))
+ .collect()
+}
+
+/// `extra_camera_keys` for clip `clip_index` of `scene`, from the clip's own camera files.
+fn scene_extra_camera_keys(scene: &Scene, clip_index: usize) -> ExtraCameraKeys {
+ let sources = scene.clips.get(clip_index).map(|c| c.additional_cameras.as_slice()).unwrap_or(&[]);
+ extra_camera_keys(&extra_cameras_for_clip(scene, clip_index), sources)
+}
+
+/// The windowed layout regions of clip `clip_index` (what `camera_layers_at` plans from).
+fn clip_layout_regions(scene: &Scene, clip_index: usize) -> Vec {
+ if scene.camera_layout_regions.is_empty() {
+ return Vec::new();
+ }
+ scene_for_clip(scene, clip_index).camera_layout_regions
+}
+
+fn same_extra_key(a: &Option, b: &Option) -> bool {
+ match (a, b) {
+ (None, None) => true,
+ (Some(a), Some(b)) => a.path == b.path && (a.offset_sec - b.offset_sec).abs() < 1e-9,
+ _ => false,
+ }
+}
+
+fn same_extra_keys(a: &[Option], b: &[Option]) -> bool {
+ a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same_extra_key(a, b))
+}
+
+/// Same spans and same cameras in each: what decides when an extra camera is active
+/// (`extra_camera_active`). Rects and shapes do not matter to the decoders.
+fn same_region_timing(a: &[SceneCameraLayoutRegion], b: &[SceneCameraLayoutRegion]) -> bool {
+ a.len() == b.len()
+ && a.iter().zip(b).all(|(a, b)| {
+ a.clip_index == b.clip_index
+ && a.start_sec == b.start_sec
+ && a.end_sec == b.end_sec
+ && a.layers.len() == b.layers.len()
+ && a.layers.iter().zip(&b.layers).all(|(x, y)| x.camera == y.camera)
+ })
+}
+
+/// Whether `set_extra_cameras` seeks the extra cameras right away. Playing on (`steady`:
+/// playing, and the screen position did not jump) with the same files and the same region
+/// timing, the next playback step (`advance_extras(.., false)`) moves them on: seeking every
+/// camera on each scene push would stall the preview while it plays.
+fn extras_need_seek(keys_changed: bool, timing_changed: bool, steady: bool) -> bool {
+ keys_changed || timing_changed || !steady
+}
+
+/// Opens the extra cameras `keys` asks for, without seeking them: they are positioned when
+/// they become active (`Player::advance_extras`).
+unsafe fn open_extra_cameras(keys: &[Option], gpu: &Gpu) -> Vec> {
+ keys.iter()
+ .map(|key| {
+ let key = key.as_ref()?;
+ let dec = opened_or_skipped(&key.path, Decoder::open(&key.path, gpu))?;
+ Some(ExtraCamera { dec, offset_sec: key.offset_sec, shown: false, ended_at: None })
+ })
+ .collect()
+}
+
unsafe fn open_and_seek_clip(
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
+ extra_keys: &[Option],
source_time_sec: f64,
gpu: &Gpu,
) -> Result {
let source_time_sec = source_time_sec.max(0.0);
let mut sdec = Decoder::open(screen_path, gpu)?;
let (mut wdec, webcam_decoder_is_real) = open_webcam_or_stand_in(screen_path, webcam_path, gpu)?;
- let sf = sdec.seek_to(source_time_sec)?;
- let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?;
- if wf.is_null() {
- wf = wdec.seek_to(0.0)?;
+ let extra = open_extra_cameras(extra_keys, gpu);
+ let sf = sdec.seek_to_or_last(source_time_sec)?;
+ if webcam_decoder_is_real && wdec.seek_to(camera_source_time(source_time_sec, webcam_offset_sec))?.is_null() {
+ wdec.seek_to(0.0)?;
}
if sf.is_null() {
anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")");
}
let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32;
let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok();
- Ok(PrefetchedClip { sdec, wdec, webcam_decoder_is_real, webcam_offset_sec, idx, cursor_track })
+ Ok(PrefetchedClip {
+ sdec,
+ wdec,
+ webcam_decoder_is_real,
+ webcam_offset_sec,
+ extra,
+ extra_keys: extra_keys.to_vec(),
+ idx,
+ cursor_track,
+ })
}
/// Nombre de paires de décodeurs INACTIVES gardées ouvertes en plus de la paire active.
@@ -174,11 +297,29 @@ unsafe fn open_and_seek_clip(
/// les timelines 2-4 clips, à baisser si la VRAM serre.
const DECODER_POOL_CAP: usize = 3;
-/// Une paire de décodeurs mise de côté, prête à être réactivée sans réouverture.
-struct PooledClip {
+/// What a set of open decoders was opened for: the screen, camera 0 and the extra cameras.
+/// A pooled set is reused only for the same key, extra cameras included — a pooled pair
+/// opened without camera 2 must not stand in for a clip whose layout shows it.
+#[derive(Clone)]
+struct ClipKey {
screen_path: String,
webcam_path: String,
webcam_offset_sec: f64,
+ extras: ExtraCameraKeys,
+}
+
+impl ClipKey {
+ fn matches(&self, other: &ClipKey) -> bool {
+ self.screen_path == other.screen_path
+ && self.webcam_path == other.webcam_path
+ && (self.webcam_offset_sec - other.webcam_offset_sec).abs() < 1e-9
+ && same_extra_keys(&self.extras, &other.extras)
+ }
+}
+
+/// Une paire de décodeurs mise de côté, prête à être réactivée sans réouverture.
+struct PooledClip {
+ key: ClipKey,
clip: PrefetchedClip,
}
@@ -189,14 +330,19 @@ struct PooledClip {
unsafe fn seek_pair(
sdec: &mut Decoder,
wdec: &mut Decoder,
+ webcam_decoder_is_real: bool,
source_time_sec: f64,
webcam_offset_sec: f64,
) -> Result {
- let sf = sdec.seek_to(source_time_sec)?;
+ let sf = sdec.seek_to_or_last(source_time_sec)?;
if sf.is_null() {
return Ok(false);
}
- let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?;
+ // Le remplaçant n'est jamais lu (`Player::webcam_frame`) : rien à positionner.
+ if !webcam_decoder_is_real {
+ return Ok(true);
+ }
+ let mut wf = wdec.seek_to(camera_source_time(source_time_sec, webcam_offset_sec))?;
if wf.is_null() {
wf = wdec.seek_to(0.0)?;
}
@@ -215,50 +361,41 @@ unsafe fn seek_pair(
unsafe fn swap_clip_pooled(
player: &mut Player,
pool: &mut Vec,
- request: &ActiveClipRequest,
- active_screen: &str,
- active_webcam: &str,
- active_webcam_offset_sec: f64,
+ request: &ClipKey,
+ source_time_sec: f64,
+ active: &ClipKey,
) -> Result<()> {
let timing = std::env::var("OPENSCREEN_CLIPSWITCH_TIMING").is_ok();
let t0 = std::time::Instant::now();
- let t = request.source_time_sec.max(0.0);
- let matches = |p: &PooledClip| {
- p.screen_path == request.screen_path
- && p.webcam_path == request.webcam_path
- && (p.webcam_offset_sec - request.webcam_offset_sec).abs() < 1e-9
- };
+ let t = source_time_sec.max(0.0);
let mut hit = false;
- let incoming: PrefetchedClip = match pool.iter().position(&matches) {
+ let incoming: PrefetchedClip = match pool.iter().position(|p| p.key.matches(request)) {
Some(i) => {
let mut pooled = pool.remove(i);
// Reseek les décodeurs poolés AVANT de les installer. Échec → on les jette et on
// ouvre à neuf (chemin connu sûr), jamais une frame vide.
- if seek_pair(&mut pooled.clip.sdec, &mut pooled.clip.wdec, t, request.webcam_offset_sec)? {
+ if seek_pair(
+ &mut pooled.clip.sdec,
+ &mut pooled.clip.wdec,
+ pooled.clip.webcam_decoder_is_real,
+ t,
+ request.webcam_offset_sec,
+ )? {
pooled.clip.idx = (t * pooled.clip.sdec.fps()).round().max(0.0) as u32;
hit = true;
pooled.clip
} else {
drop(pooled);
- player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?
+ player.open_clip(request, t)?
}
}
- None => player.open_clip(&request.screen_path, &request.webcam_path, request.webcam_offset_sec, t)?,
+ None => player.open_clip(request, t)?,
};
let outgoing = player.swap_active(incoming);
// Met la paire quittée en pool : dédup par clé (jamais deux entrées d'un même média), puis
// éviction LRU (le plus ancien, en tête, part en premier).
- pool.retain(|p| {
- !(p.screen_path == active_screen
- && p.webcam_path == active_webcam
- && (p.webcam_offset_sec - active_webcam_offset_sec).abs() < 1e-9)
- });
- pool.push(PooledClip {
- screen_path: active_screen.to_string(),
- webcam_path: active_webcam.to_string(),
- webcam_offset_sec: active_webcam_offset_sec,
- clip: outgoing,
- });
+ pool.retain(|p| !p.key.matches(active));
+ pool.push(PooledClip { key: active.clone(), clip: outgoing });
while pool.len() > DECODER_POOL_CAP {
pool.remove(0);
}
@@ -285,6 +422,12 @@ pub struct Player {
/// (`swap_active`), lu par la boucle de rendu pour décider de dessiner la vignette.
webcam_decoder_is_real: bool,
webcam_offset_sec: f64,
+ /// The active clip's extra cameras (index k-1 = camera k), what they were opened for, and
+ /// the clip's windowed layout regions that decide when each one is decoded. All empty
+ /// without layout regions: no decoder, nothing to advance.
+ extra: Vec>,
+ extra_keys: ExtraCameraKeys,
+ camera_regions: Vec,
has_current_frame: bool,
use_current_on_next_step: bool,
idx: u32,
@@ -307,6 +450,9 @@ impl Player {
},
webcam_decoder_is_real,
webcam_offset_sec: 0.0,
+ extra: Vec::new(),
+ extra_keys: Vec::new(),
+ camera_regions: Vec::new(),
has_current_frame: false,
use_current_on_next_step: false,
idx: 0,
@@ -325,10 +471,17 @@ impl Player {
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
+ extra_keys: &[Option],
source_time_sec: f64,
) -> Result<()> {
- let prefetched =
- open_and_seek_clip(screen_path, webcam_path, webcam_offset_sec, source_time_sec, &self.gpu)?;
+ let prefetched = open_and_seek_clip(
+ screen_path,
+ webcam_path,
+ webcam_offset_sec,
+ extra_keys,
+ source_time_sec,
+ &self.gpu,
+ )?;
self.apply_prefetched(prefetched);
Ok(())
}
@@ -359,7 +512,13 @@ impl Player {
// Les DEUX flux doivent avoir une frame : `compose_frame` les échantillonne tous les
// deux sans condition, un seul manquant suffit à le faire échouer (d'où le `false` que
// `seek_pair` peut rendre → l'appelant retombe sur l'ouverture complète).
- if !seek_pair(&mut self.sdec, &mut self.wdec, source_time_sec, self.webcam_offset_sec)? {
+ if !seek_pair(
+ &mut self.sdec,
+ &mut self.wdec,
+ self.webcam_decoder_is_real,
+ source_time_sec,
+ self.webcam_offset_sec,
+ )? {
return Ok(false);
}
self.idx = (source_time_sec * self.sdec.fps()).round().max(0.0) as u32;
@@ -389,6 +548,8 @@ impl Player {
// recalculer), l'entrante impose la sienne au player.
webcam_decoder_is_real: self.webcam_decoder_is_real,
webcam_offset_sec: self.webcam_offset_sec,
+ extra: std::mem::replace(&mut self.extra, incoming.extra),
+ extra_keys: std::mem::replace(&mut self.extra_keys, incoming.extra_keys),
idx: self.idx,
// Le curseur est re-dérivé du chemin à la réactivation ; inutile de le trimballer.
cursor_track: None,
@@ -403,14 +564,107 @@ impl Player {
/// Ouvre une nouvelle paire de décodeurs positionnée à `source_time_sec`, SANS l'installer
/// (l'appelant l'échange via `swap_active`). Réutilise le device D3D11 du player.
- unsafe fn open_clip(
- &self,
- screen: &str,
- webcam: &str,
- webcam_offset_sec: f64,
- source_time_sec: f64,
- ) -> Result {
- open_and_seek_clip(screen, webcam, webcam_offset_sec, source_time_sec, &self.gpu)
+ unsafe fn open_clip(&self, key: &ClipKey, source_time_sec: f64) -> Result {
+ open_and_seek_clip(
+ &key.screen_path,
+ &key.webcam_path,
+ key.webcam_offset_sec,
+ &key.extras,
+ source_time_sec,
+ &self.gpu,
+ )
+ }
+
+ /// What the active extra cameras were opened for (the extra half of the pool key).
+ fn extra_keys(&self) -> &[Option] {
+ &self.extra_keys
+ }
+
+ /// Installs the active clip's windowed layout regions and the extra cameras they need
+ /// (`keys`): slots whose key is unchanged keep their decoder, the others are closed and
+ /// (re)opened — a file that will not open leaves its slot empty, once. Then positions every
+ /// camera that is near one of its regions at the current screen time, so a paused
+ /// recompose shows it at once — unless nothing that matters to the decoders changed while
+ /// playback goes on (`steady`, see `extras_need_seek`). `true` when a decoder was closed:
+ /// the caller must then clear the compositor's SRV cache (which also forgets the extra
+ /// frame pointers).
+ pub(crate) unsafe fn set_extra_cameras(
+ &mut self,
+ regions: Vec,
+ keys: ExtraCameraKeys,
+ steady: bool,
+ ) -> bool {
+ let timing_changed = !same_region_timing(&self.camera_regions, ®ions);
+ let keys_changed = !same_extra_keys(&self.extra_keys, &keys);
+ self.camera_regions = regions;
+ let mut closed = false;
+ if keys_changed {
+ let mut old = std::mem::take(&mut self.extra);
+ let mut extra = Vec::with_capacity(keys.len());
+ for (k, key) in keys.iter().enumerate() {
+ let old_cam = old.get_mut(k).and_then(Option::take);
+ let old_key = self.extra_keys.get(k).cloned().flatten();
+ if same_extra_key(&old_key, key) {
+ extra.push(old_cam);
+ continue;
+ }
+ closed |= old_cam.is_some();
+ drop(old_cam);
+ extra.extend(open_extra_cameras(std::slice::from_ref(key), &self.gpu));
+ }
+ closed |= old.iter().any(Option::is_some);
+ self.extra = extra;
+ self.extra_keys = keys;
+ }
+ if !extras_need_seek(keys_changed, timing_changed, steady) {
+ return closed;
+ }
+ let t = self.sdec.cur_time_sec();
+ closed | self.advance_extras(t, true).1
+ }
+
+ /// The extra cameras' frames at screen source time `screen_t`, each camera stepped
+ /// (`seek == false`) or sought toward its own source time only while it is near one of
+ /// its regions; an idle camera is left alone and gives null. A camera whose decoder fails
+ /// is closed with one warning — its layer disappears, the preview goes on. The `bool` says
+ /// a decoder was closed (see `set_extra_cameras`).
+ unsafe fn advance_extras(&mut self, screen_t: f64, seek: bool) -> (Vec<*const AVFrame>, bool) {
+ let mut closed = false;
+ let mut frames = Vec::with_capacity(self.extra.len());
+ for (k, slot) in self.extra.iter_mut().enumerate() {
+ let camera = k + 1;
+ let frame = match slot {
+ Some(cam) if extra_camera_active(&self.camera_regions, camera, screen_t) => {
+ let target = camera_source_time(screen_t, cam.offset_sec);
+ let result = if seek { cam.seek(target) } else { cam.step_to(target) };
+ match result {
+ Ok(frame) => frame,
+ Err(e) => {
+ eprintln!("WARNING: extra camera {camera} stopped decoding: {e:#}. Its layer will not be drawn.");
+ *slot = None;
+ closed = true;
+ std::ptr::null()
+ }
+ }
+ }
+ Some(cam) => {
+ cam.shown = false;
+ std::ptr::null()
+ }
+ None => std::ptr::null(),
+ };
+ frames.push(frame);
+ }
+ (frames, closed)
+ }
+
+ /// Hands the extra cameras' frames to `comp` for the next `compose_frame`. Called before
+ /// every compose: the compositor keeps the pointers until the next call.
+ unsafe fn hand_extra_frames(comp: &Compositor, frames: &[*const AVFrame], closed: bool) {
+ if closed {
+ comp.clear_srv_cache();
+ }
+ comp.set_extra_camera_frames(frames);
}
/// Le décodeur webcam ACTIF est-il la vraie caméra ? `false` quand c'est le remplaçant
@@ -421,6 +675,17 @@ impl Player {
self.webcam_decoder_is_real
}
+ /// La frame webcam à composer avec la frame écran `screen` : celle de la caméra, ou sans
+ /// caméra la frame écran elle-même, que le remplaçant aurait décodée à l'identique et que
+ /// la composition ne dessine pas (`should_draw_webcam`).
+ unsafe fn webcam_frame(&self, screen: *mut crate::ffi::AVFrame) -> *mut crate::ffi::AVFrame {
+ if self.webcam_decoder_is_real {
+ self.wdec.cur_frame()
+ } else {
+ screen
+ }
+ }
+
/// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le
/// franchissement de la fin de fenêtre du clip actif pendant la lecture libre, et pour
/// calculer la cible de `step` en lecture libre. `pub` (pas `pub(crate)`) : le harnais
@@ -429,6 +694,12 @@ impl Player {
self.sdec.cur_time_sec()
}
+ /// Temps source courant du décodeur webcam, le remplaçant compris : sans caméra il ne doit
+ /// jamais bouger (`tests/no_camera_stand_in.rs`).
+ pub unsafe fn webcam_time_sec(&self) -> f64 {
+ self.wdec.cur_time_sec()
+ }
+
/// Recalcule l'horloge programme pour le clip `clip_index` de `scene` (la scène COMPLÈTE,
/// pas la fenêtre d'un clip : il faut la durée de sortie des clips précédents).
///
@@ -509,7 +780,10 @@ impl Player {
}
let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0);
- let wf = if use_current {
+ let wf = if !self.webcam_decoder_is_real {
+ // Pas de caméra : le remplaçant n'est pas décodé (cf. `open_webcam_or_stand_in`).
+ sf
+ } else if use_current {
self.wdec.cur_frame()
} else {
let cur = self.wdec.cur_frame();
@@ -557,6 +831,8 @@ impl Player {
self.has_current_frame = true;
self.sync_time(comp);
+ let (extra, closed) = self.advance_extras(self.sdec.cur_time_sec(), false);
+ Self::hand_extra_frames(comp, &extra, closed);
comp.compose_frame(sf, wf, self.idx as f32, cfg)?;
self.idx = self.idx.wrapping_add(1);
Ok(true)
@@ -582,11 +858,15 @@ impl Player {
return Ok(false);
}
let sf = self.sdec.cur_frame();
- let wf = self.wdec.cur_frame();
+ let wf = self.webcam_frame(sf);
if sf.is_null() || wf.is_null() {
return Ok(false);
}
self.sync_time(comp);
+ let extra = extra_frame_list(&self.extra, |cam| {
+ if cam.shown { cam.dec.cur_frame() as *const AVFrame } else { std::ptr::null() }
+ });
+ comp.set_extra_camera_frames(&extra);
let f = self.idx.saturating_sub(1);
comp.compose_frame(sf, wf, f as f32, cfg)?;
Ok(true)
@@ -598,10 +878,15 @@ impl Player {
/// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de
/// `set_time`, incorrect au-delà de 6s sur un enregistrement réel).
pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result {
- let sf = self.sdec.seek_to(target_sec)?;
- let wf = self
- .wdec
- .seek_to(webcam_seek_time(target_sec, self.webcam_offset_sec))?;
+ let sf = self.sdec.seek_to_or_last(target_sec)?;
+ // La caméra aussi, au-delà de sa dernière image comprise : sans frame webcam, la frame
+ // ne serait pas composée. Sans caméra, la frame écran tient sa place.
+ let wf = if self.webcam_decoder_is_real {
+ self.wdec
+ .seek_to_or_last(camera_source_time(target_sec, self.webcam_offset_sec))?
+ } else {
+ sf
+ };
if sf.is_null() || wf.is_null() {
self.has_current_frame = false;
return Ok(false);
@@ -612,6 +897,8 @@ impl Player {
// "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé
// du temps réel pour rester cohérent si jamais consulté.
self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32;
+ let (extra, closed) = self.advance_extras(target_sec, true);
+ Self::hand_extra_frames(comp, &extra, closed);
comp.compose_frame(sf, wf, self.idx as f32, cfg)?;
Ok(true)
}
@@ -690,6 +977,9 @@ struct ActiveClipRequest {
screen_path: String,
webcam_path: String,
webcam_offset_sec: f64,
+ /// Cameras 2-4 of the clip (index k-1 = camera k); which of them open is decided by the
+ /// clip's layout regions (`extra_cameras_for_clip`).
+ additional_cameras: Vec,
/// Identité dans le flux `Scene.clips` trié (les chemins ne suffisent pas pour un asset partagé).
clip_index: usize,
/// Playhead exprimé sur l'horloge source écran du nouveau clip.
@@ -756,15 +1046,34 @@ fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene {
/// Dernière frame readback vers CPU, prête pour le napi `read_frame`.
///
-/// `(gen, w, h, vec)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed (R, G, B, A
-/// en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone
+/// `(gen, w, h, vec, métrage, position)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed
+/// (R, G, B, A en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone
/// (≥ 1, `0` réservé à « le consommateur n'a encore rien vu ») incrémentée à CHAQUE
/// publication, càd uniquement quand une nouvelle frame a réellement été composée (le
/// thread de rendu ne republie pas une frame identique — cf. `stepped || first`). Elle
/// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC
/// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les
/// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide).
-type LatestFrame = (u64, u32, u32, Vec);
+pub type LatestFrame = (
+ u64,
+ u32,
+ u32,
+ Vec,
+ Option,
+ FramePosition,
+);
+
+/// Où en est la vue quand elle compose une frame : le clip actif de la scène et le temps
+/// source de la frame écran. Voyage avec chaque frame publiée, quel que soit le transport,
+/// pour que l'app compare la position RÉELLE de la vue à sa propre tête de lecture au lieu
+/// de la deviner à partir de l'horloge murale.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct FramePosition {
+ /// Index du clip actif dans `scene.clips`.
+ pub clip_index: u32,
+ /// pts de la frame écran composée, en secondes du fichier source.
+ pub source_time_sec: f64,
+}
/// État partagé thread appelant → thread de rendu (commandes sans blocage).
struct Shared {
@@ -800,6 +1109,15 @@ struct Shared {
/// vide la ferait repartir à 1 — donc rejouer des générations déjà peintes. Monotone,
/// jamais remise à zéro.
frame_gen: AtomicU64,
+ /// La vue livre ses frames dans l'anneau de textures partagées (`take_shared_frame`) au
+ /// lieu de les relire en RAM (`latest_frame_since`). Posé par JS ; le thread de rendu le
+ /// rabat à `false` si l'anneau ne peut pas servir, et les frames repassent par la RAM.
+ shared_frames: AtomicBool,
+ /// Tenue des cases de l'anneau, entre le thread de rendu et le thread Node.
+ slot_book: Mutex,
+ /// Republier la frame courante au prochain tour, même en pause : posé quand le transport
+ /// change, pour que le consommateur ait une image sans attendre que quelque chose bouge.
+ republish: AtomicBool,
/// Erreur fatale du thread de rendu (device D3D11 introuvable, décodeur qui refuse
/// le fichier…). Le thread meurt sur la première erreur ; sans ce champ, elle
/// finissait dans un `eprintln!` que personne ne lit et l'utilisateur n'avait
@@ -853,6 +1171,9 @@ impl LiveView {
stop: AtomicBool::new(false),
latest_frame: Mutex::new(None),
frame_gen: AtomicU64::new(0),
+ shared_frames: AtomicBool::new(false),
+ slot_book: Mutex::new(SlotBook::default()),
+ republish: AtomicBool::new(false),
fatal: Mutex::new(None),
});
let sh = shared.clone();
@@ -894,7 +1215,7 @@ impl LiveView {
/// FFI vers le Buffer napi). Le `Vec` retourné a `len() == w*h*4`.
/// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand
/// le consommateur possède déjà la génération courante.
- pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec)> {
+ pub fn latest_frame(&self) -> Option {
self.shared
.latest_frame
.lock()
@@ -910,7 +1231,7 @@ impl LiveView {
/// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du
/// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au
/// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`.
- pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> {
+ pub fn latest_frame_since(&self, since_gen: u64) -> Option {
let mut guard = self.shared.latest_frame.lock().ok()?;
match guard.as_ref() {
// Le buffer est EMPORTÉ, pas copié. Le thread de rendu le remplace à chaque
@@ -929,6 +1250,35 @@ impl LiveView {
}
}
+ /// Livrer les frames par textures partagées plutôt que par readback. Seul le backend
+ /// matériel de Windows sait le faire : ailleurs la demande est refusée et la vue continue
+ /// de relire en RAM. Rend l'état obtenu.
+ pub fn set_shared_frames(&self, enabled: bool) -> bool {
+ let on = enabled && cfg!(windows) && Gpu::probe() == Some(Backend::Hardware);
+ if self.shared.shared_frames.swap(on, Ordering::Relaxed) != on {
+ if !on {
+ if let Ok(mut book) = self.shared.slot_book.lock() {
+ book.clear_ready();
+ }
+ }
+ self.shared.republish.store(true, Ordering::Relaxed);
+ }
+ on
+ }
+
+ /// La dernière frame posée dans l'anneau, si elle est plus récente que `since_gen`. Sa
+ /// case reste tenue jusqu'à `release_shared_frame`. `None` aussi quand la vue relit en RAM.
+ pub fn take_shared_frame(&self, since_gen: u64) -> Option {
+ self.shared.slot_book.lock().ok()?.take(since_gen, Instant::now())
+ }
+
+ /// Chromium a relâché la frame `gen` de la case `slot` : le thread de rendu peut y réécrire.
+ pub fn release_shared_frame(&self, slot: u32, gen: u64) {
+ if let Ok(mut book) = self.shared.slot_book.lock() {
+ book.release(slot, gen);
+ }
+ }
+
/// Switch inspector (booléen).
pub fn set_param_bool(&self, key: &str, value: bool) {
if let Ok(mut p) = self.shared.inspector.lock() {
@@ -1026,6 +1376,7 @@ impl LiveView {
screen_path: &str,
webcam_path: &str,
webcam_offset_sec: f64,
+ additional_cameras: Vec,
clip_index: usize,
source_time_sec: f64,
) {
@@ -1034,6 +1385,7 @@ impl LiveView {
screen_path: screen_path.to_string(),
webcam_path: webcam_path.to_string(),
webcam_offset_sec,
+ additional_cameras,
clip_index,
source_time_sec: source_time_sec.max(0.0),
});
@@ -1132,7 +1484,7 @@ type PendingPrefetch = (usize, std::sync::mpsc::Receiver>
/// tâche de fond. Assez large pour couvrir un `Decoder::open` typique (ouverture fichier +
/// `avformat_find_stream_info` + init D3D11VA), assez court pour ne pas garder deux paires de
/// décodeurs ouvertes plus longtemps que nécessaire.
-const PREFETCH_LEAD_SEC: f64 = 0.75;
+pub(crate) const PREFETCH_LEAD_SEC: f64 = 0.75;
/// Durée pendant laquelle la boucle continue de recomposer après un changement en pause, le
/// temps qu'un effet asynchrone (segmentation webcam) livre son résultat. Généreuse : à
@@ -1186,6 +1538,7 @@ unsafe fn maybe_start_prefetch(
0
};
let next_clip = scene.clips[next_index].clone();
+ let extra_keys = scene_extra_camera_keys(scene, next_index);
// Copie légère (COM refcount, pas de nouveau device) — même motif que `Player::open`.
let gpu_clone = Gpu {
device: gpu.device.clone(),
@@ -1200,6 +1553,7 @@ unsafe fn maybe_start_prefetch(
&next_clip.screen_path,
&next_clip.webcam_path,
next_clip.webcam_offset_sec,
+ &extra_keys,
next_clip.source_start_sec,
&gpu_clone,
)
@@ -1237,6 +1591,7 @@ unsafe fn advance_to_next_scene_clip(
active_screen_path: &mut String,
active_webcam_path: &mut String,
active_webcam_offset_sec: &mut f64,
+ active_additional_cameras: &mut Vec,
active_clip_index: &mut usize,
raw_cursor: &mut Option,
loaded_cursor_path: &mut String,
@@ -1251,6 +1606,7 @@ unsafe fn advance_to_next_scene_clip(
0
};
let next_clip = &scene.clips[next_index];
+ let extra_keys = scene_extra_camera_keys(scene, next_index);
// N'importe quel préchargement en cours ne concerne plus que CETTE frontière (on vient
// de la franchir, bien ou mal ciblée) — on le consomme s'il correspond, on l'abandonne
@@ -1278,6 +1634,7 @@ unsafe fn advance_to_next_scene_clip(
&next_clip.screen_path,
&next_clip.webcam_path,
next_clip.webcam_offset_sec,
+ &extra_keys,
next_clip.source_start_sec,
)
}
@@ -1285,6 +1642,7 @@ unsafe fn advance_to_next_scene_clip(
&next_clip.screen_path,
&next_clip.webcam_path,
next_clip.webcam_offset_sec,
+ &extra_keys,
next_clip.source_start_sec,
),
};
@@ -1296,12 +1654,18 @@ unsafe fn advance_to_next_scene_clip(
// sur l'adresse de la texture, et garder des entrées d'un décodeur fermé fait
// fuir de la VRAM puis, en cas de réutilisation d'adresse, rendre l'image du clip
// précédent.
+ let windowed = scene_for_clip(scene, next_index);
+ // The prefetch opened exactly these keys, so this only installs the regions and
+ // positions the cameras; the cache is cleared right below in any case.
+ // The screen position jumped to the next clip: always position the cameras.
+ player.set_extra_cameras(windowed.camera_layout_regions.clone(), extra_keys, false);
comp.clear_srv_cache();
*active_screen_path = next_clip.screen_path.clone();
*active_webcam_path = next_clip.webcam_path.clone();
*active_webcam_offset_sec = next_clip.webcam_offset_sec;
+ *active_additional_cameras = next_clip.additional_cameras.clone();
*active_clip_index = next_index;
- comp.set_scene(Some(scene_for_clip(scene, *active_clip_index)));
+ comp.set_scene(Some(windowed));
player.set_programme_clock(Some(scene), *active_clip_index);
// Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon
// (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone
@@ -1379,6 +1743,9 @@ unsafe fn render_thread(
let mut active_screen_path = screen.to_string();
let mut active_webcam_path = webcam.to_string();
let mut active_webcam_offset_sec = 0.0f64;
+ // Cameras 2-4 of the active clip, as the app (`set_active_clip`) or the scene (auto-advance)
+ // gave them. The view starts without: `create_view` carries camera 0 only.
+ let mut active_additional_cameras: Vec = Vec::new();
let mut active_clip_index = 0usize;
// Copie de la Scene complète (tous les clips), tenue à jour à chaque push de l'app —
// permet à la boucle de lecture libre de connaître la fenêtre source
@@ -1426,8 +1793,19 @@ unsafe fn render_thread(
// appliquée, on refuse de jouer le layout fixture (POC) : un fallback fixture ne ferait que
// MASQUER un scene-push cassé. On attend la scène avant de produire le 1er frame.
let mut scene_applied = false;
+ // Textures partagées de la preview, créées au premier besoin (voir `publish_shared`).
+ let mut ring = Ring::default();
+ // Une frame composée qui n'a trouvé aucune case libre dans l'anneau : le RT la garde, elle
+ // repart dès qu'une case se libère. Sans ça, une frame composée en pause (seek, réglage)
+ // restait invisible jusqu'au changement suivant — en lecture, la suivante la remplace.
+ let mut publish_pending = false;
while !shared.stop.load(Ordering::SeqCst) {
+ // Le transport vient de changer : le consommateur doit recevoir la frame courante
+ // sans attendre qu'une autre soit composée (en pause, il n'y en aurait pas).
+ if shared.republish.swap(false, Ordering::Relaxed) {
+ first = true;
+ }
// params inspector : booléens/taps → cfg ; valeurs continues → live_params
let ip = *shared.inspector.lock().unwrap();
let mut clip_changed = false;
@@ -1448,6 +1826,26 @@ unsafe fn render_thread(
// JAMAIS fatal : on retombe sur l'ouverture complète, chemin connu comme sûr.
// L'optimisation ne s'applique donc que là où elle fonctionne démontrablement.
let repositioned = same_media && matches!(player.seek_active(request.source_time_sec), Ok(true));
+ // The extra cameras this clip's layout regions show, from the files the request
+ // carries. Resolved against the scene before the switch so that the pool and the
+ // fresh open both see the full key.
+ let request_scene = shared.scene.lock().unwrap().clone();
+ let request_clip_index = request_scene.as_ref().and_then(|scene| {
+ resolve_scene_clip_index(
+ scene,
+ request.clip_index,
+ &request.screen_path,
+ &request.webcam_path,
+ request.webcam_offset_sec,
+ )
+ });
+ let request_extras = match (&request_scene, request_clip_index) {
+ (Some(scene), Some(index)) => extra_camera_keys(
+ &extra_cameras_for_clip(scene, index),
+ &request.additional_cameras,
+ ),
+ _ => Vec::new(),
+ };
let switch_result = if repositioned {
Ok(())
} else {
@@ -1455,13 +1853,24 @@ unsafe fn render_thread(
// ouverte si possible (reseek au lieu de rouvrir) et met en pool celle qu'on
// quitte, au lieu du couple ouvrir-puis-fermer. Voir `swap_clip_pooled` et la
// mesure de ~120 ms/franchissement qui l'a motivé.
+ let incoming = ClipKey {
+ screen_path: request.screen_path.clone(),
+ webcam_path: request.webcam_path.clone(),
+ webcam_offset_sec: request.webcam_offset_sec,
+ extras: request_extras.clone(),
+ };
+ let active = ClipKey {
+ screen_path: active_screen_path.clone(),
+ webcam_path: active_webcam_path.clone(),
+ webcam_offset_sec: active_webcam_offset_sec,
+ extras: player.extra_keys().to_vec(),
+ };
swap_clip_pooled(
&mut player,
&mut decoder_pool,
- &request,
- &active_screen_path,
- &active_webcam_path,
- active_webcam_offset_sec,
+ &incoming,
+ request.source_time_sec,
+ &active,
)
};
match switch_result {
@@ -1483,8 +1892,18 @@ unsafe fn render_thread(
active_screen_path = request.screen_path;
active_webcam_path = request.webcam_path;
active_webcam_offset_sec = request.webcam_offset_sec;
- let scene = shared.scene.lock().unwrap().clone();
+ active_additional_cameras = request.additional_cameras;
+ let scene = request_scene;
full_scene = scene.clone();
+ // No scene clip for the request → no layout regions (and no extras).
+ let regions = match (&scene, request_clip_index) {
+ (Some(s), Some(index)) => clip_layout_regions(s, index),
+ _ => Vec::new(),
+ };
+ // A clip request moves the screen position: position the cameras.
+ if player.set_extra_cameras(regions, request_extras, false) {
+ comp.clear_srv_cache();
+ }
if let Some(base_scene) = scene {
if let Some(index) = resolve_scene_clip_index(
&base_scene,
@@ -1590,6 +2009,7 @@ unsafe fn render_thread(
prefetch = None;
let scene = shared.scene.lock().unwrap().clone();
full_scene = scene.clone();
+ let mut clip_resolved = false;
let scene = scene.map(|base_scene| {
scene_applied = true;
if let Some(index) = resolve_scene_clip_index(
@@ -1600,9 +2020,35 @@ unsafe fn render_thread(
active_webcam_offset_sec,
) {
active_clip_index = index;
+ clip_resolved = true;
}
scene_for_clip(&base_scene, active_clip_index)
});
+ // The resolved scene clip's own camera files win over the last clip request's: an
+ // offset or file edited mid-clip arrives with the scene, not with a new request.
+ if clip_resolved {
+ if let Some(clip) = full_scene.as_ref().and_then(|s| s.clips.get(active_clip_index)) {
+ active_additional_cameras = clip.additional_cameras.clone();
+ }
+ }
+ // A new scene can add, move or drop the layout regions that show an extra camera:
+ // open what the active clip now shows, close what it no longer does.
+ let (regions, keys) = match (&full_scene, &scene) {
+ (Some(full), Some(windowed)) => (
+ windowed.camera_layout_regions.clone(),
+ extra_camera_keys(
+ &extra_cameras_for_clip(full, active_clip_index),
+ &active_additional_cameras,
+ ),
+ ),
+ _ => (Vec::new(), Vec::new()),
+ };
+ // A scene push leaves the screen position where it is: while playing, unchanged
+ // cameras are stepped by playback instead of sought again.
+ let steady = shared.playing.load(Ordering::Relaxed);
+ if player.set_extra_cameras(regions, keys, steady) {
+ comp.clear_srv_cache();
+ }
comp.set_scene(scene);
}
// Le temps programme dépend du clip actif ET de la scène entière (durées des clips
@@ -1717,6 +2163,7 @@ unsafe fn render_thread(
&mut active_screen_path,
&mut active_webcam_path,
&mut active_webcam_offset_sec,
+ &mut active_additional_cameras,
&mut active_clip_index,
&mut raw_cursor,
&mut loaded_cursor_path,
@@ -1745,6 +2192,7 @@ unsafe fn render_thread(
&mut active_screen_path,
&mut active_webcam_path,
&mut active_webcam_offset_sec,
+ &mut active_additional_cameras,
&mut active_clip_index,
&mut raw_cursor,
&mut loaded_cursor_path,
@@ -1783,39 +2231,56 @@ unsafe fn render_thread(
stepped = true;
}
- if stepped || first {
+ if stepped || first || publish_pending {
if pw > 0 && ph > 0 {
- // Step complet : `compose_frame` (déjà appelé par `step`/`present_frame`/
- // `recompose`) a rastérisé le RT à la géométrie de sortie ramenée au panneau.
- // On lit ce RT DIRECTEMENT à sa résolution de rendu (`readback_direct` : copy
- // rt → staging → Map/Unmap), sans le resize `blit_resized` qui, depuis la
- // refonte ratio, n'était plus qu'une copie identité + une alloc NV12 inutile.
- match comp.readback_direct() {
- Ok((rw, rh, rgba)) => {
- // Publie dans `latest_frame` : on remplace le buffer précédent
- // (le canvas ne montre que la dernière frame, peu importe combien
- // le renderer en a raté entre deux lectures napi). On incrémente
- // la génération sous le MÊME lock que l'écriture du buffer, pour
- // qu'un lecteur ne puisse jamais voir un `gen` neuf appairé à un
- // buffer périmé (ou l'inverse). `+ 1` depuis la précédente, `1` au
- // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) :
- // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à
- // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire.
- // La génération vient d'un compteur atomique et non du slot : la
- // livraison sans copie VIDE le slot en le lisant, et un
- // `unwrap_or(1)` repartirait alors de 1 — le consommateur recevrait
- // des générations déjà peintes et boucherait. Séquence identique à
- // l'ancienne dérivation tant que le slot n'est pas vidé.
- let next_gen = shared.frame_gen.fetch_add(1, Ordering::Relaxed) + 1;
- if let Ok(mut slot) = shared.latest_frame.lock() {
- *slot = Some((next_gen, rw, rh, rgba));
- }
+ let position = FramePosition {
+ clip_index: active_clip_index as u32,
+ source_time_sec: player.screen_time_sec(),
+ };
+ match publish_shared(&shared, &gpu, &comp, &mut ring, position) {
+ SharedPublish::Published => {
first = false;
+ publish_pending = false;
}
- Err(e) => {
- eprintln!("[live] readback_direct: {e:#}");
- std::thread::sleep(Duration::from_millis(8));
+ // Chromium tient toutes les cases : la frame attend dans le RT, et la boucle
+ // ne tourne pas à vide le temps qu'il en relâche une.
+ SharedPublish::NoFreeSlot => {
+ publish_pending = true;
+ std::thread::sleep(Duration::from_millis(2));
}
+ // Step complet : `compose_frame` (déjà appelé par `step`/`present_frame`/
+ // `recompose`) a rastérisé le RT à la géométrie de sortie ramenée au panneau.
+ // On lit ce RT DIRECTEMENT à sa résolution de rendu (`readback_direct` : copy
+ // rt → staging → Map/Unmap), sans le resize `blit_resized` qui, depuis la
+ // refonte ratio, n'était plus qu'une copie identité + une alloc NV12 inutile.
+ SharedPublish::Off => match comp.readback_direct() {
+ Ok((rw, rh, rgba)) => {
+ // Publie dans `latest_frame` : on remplace le buffer précédent
+ // (le canvas ne montre que la dernière frame, peu importe combien
+ // le renderer en a raté entre deux lectures napi). On incrémente
+ // la génération sous le MÊME lock que l'écriture du buffer, pour
+ // qu'un lecteur ne puisse jamais voir un `gen` neuf appairé à un
+ // buffer périmé (ou l'inverse). `+ 1` depuis la précédente, `1` au
+ // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) :
+ // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à
+ // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire.
+ // La génération vient d'un compteur atomique et non du slot : la
+ // livraison sans copie VIDE le slot en le lisant, et un
+ // `unwrap_or(1)` repartirait alors de 1 — le consommateur recevrait
+ // des générations déjà peintes et boucherait. Séquence identique à
+ // l'ancienne dérivation tant que le slot n'est pas vidé.
+ let next_gen = shared.frame_gen.fetch_add(1, Ordering::Relaxed) + 1;
+ if let Ok(mut slot) = shared.latest_frame.lock() {
+ *slot = Some((next_gen, rw, rh, rgba, comp.footage_quad(), position));
+ }
+ first = false;
+ publish_pending = false;
+ }
+ Err(e) => {
+ eprintln!("[live] readback_direct: {e:#}");
+ std::thread::sleep(Duration::from_millis(8));
+ }
+ },
}
}
} else {
@@ -1825,6 +2290,101 @@ unsafe fn render_thread(
Ok(())
}
+/// Ce que la publication dans l'anneau partagé a fait de la frame composée.
+enum SharedPublish {
+ /// Posée dans une case de l'anneau.
+ Published,
+ /// Chromium tient toutes les cases : la frame attend dans le RT qu'il en relâche une.
+ NoFreeSlot,
+ /// Transport partagé coupé ou indisponible : la frame repasse par le readback.
+ Off,
+}
+
+#[cfg(windows)]
+type Ring = Option;
+#[cfg(not(windows))]
+type Ring = ();
+
+/// Pose la frame composée dans l'anneau de textures partagées, si la vue livre ainsi.
+///
+/// Toute panne coupe le transport (`shared_frames` à `false`) et rend `Off` : la frame part
+/// par le readback, et le service, qui lit les deux, n'a rien à décider.
+#[cfg(windows)]
+unsafe fn publish_shared(
+ shared: &Shared,
+ gpu: &Gpu,
+ comp: &Compositor,
+ ring: &mut Ring,
+ position: FramePosition,
+) -> SharedPublish {
+ if !shared.shared_frames.load(Ordering::Relaxed) {
+ return SharedPublish::Off;
+ }
+ let turn_off = |why: String| {
+ eprintln!("[live] textures partagées coupées ({why}) — retour au readback");
+ shared.shared_frames.store(false, Ordering::Relaxed);
+ SharedPublish::Off
+ };
+ // WARP ne partage rien avec le device de Chromium.
+ if gpu.backend != Backend::Hardware {
+ return turn_off("backend logiciel".into());
+ }
+ if ring.is_none() {
+ match SharedRing::new(gpu) {
+ Ok(created) => *ring = Some(created),
+ Err(e) => return turn_off(format!("{e:#}")),
+ }
+ }
+ let Some(ring) = ring.as_mut() else {
+ return SharedPublish::Off;
+ };
+ let (claimed, lost) = match shared.slot_book.lock() {
+ Ok(mut book) => (book.claim(crate::shared_frames::RING_SLOTS), book.lost(Instant::now())),
+ Err(_) => (None, false),
+ };
+ let Some(slot) = claimed else {
+ // Chromium relâche d'ordinaire une case dans la milliseconde. Une case perdue ne
+ // reviendra plus : la réécrire déchirerait peut-être une image qu'il lit encore, et
+ // l'attendre figerait la preview.
+ if lost {
+ return turn_off("une case jamais relâchée par Chromium".into());
+ }
+ return SharedPublish::NoFreeSlot;
+ };
+ let (width, height) = comp.render_size();
+ match ring.write(slot, comp.render_target(), width, height) {
+ Ok(handle) => {
+ if let Ok(mut book) = shared.slot_book.lock() {
+ // Le compteur du readback : une seule suite de générations quel que soit le
+ // transport, incrémentée sous le lock de la publication comme là-bas.
+ let gen = shared.frame_gen.fetch_add(1, Ordering::Relaxed) + 1;
+ book.publish(SharedFrame {
+ gen,
+ slot,
+ handle,
+ width,
+ height,
+ footage: comp.footage_quad(),
+ position,
+ });
+ }
+ SharedPublish::Published
+ }
+ Err(e) => turn_off(format!("{e:#}")),
+ }
+}
+
+#[cfg(not(windows))]
+unsafe fn publish_shared(
+ _: &Shared,
+ _: &Gpu,
+ _: &Compositor,
+ _: &mut Ring,
+ _: FramePosition,
+) -> SharedPublish {
+ SharedPublish::Off
+}
+
// ---------- harnais standalone (poc-d3d.exe --live) ----------
//
// Le harnais crée une fenêtre Win32 simple qui héberge la preview live ; c'est un
@@ -1958,7 +2518,7 @@ pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<(
// standalone n'affiche pas réellement les pixels ici (l'embed Electron est
// le consumer réel). On imprime juste une frame de temps en temps pour
// confirmer que la chaîne fonctionne.
- if let Some((_gen, fw, fh, _pixels)) = view.latest_frame() {
+ if let Some((_gen, fw, fh, _pixels, _, _)) = view.latest_frame() {
if (fw, fh) != (w, h) {
// garde-fou : la staging de readback suit `set_rect` côté thread
// de rendu, donc ce serait une désynchro transitoire — acceptable.
@@ -2092,8 +2652,147 @@ mod tests {
#[test]
fn webcam_seek_uses_screen_source_time_and_offset() {
- assert_eq!(webcam_seek_time(22.5, 1.25), 21.25);
- assert_eq!(webcam_seek_time(0.5, 1.25), 0.0);
+ assert_eq!(camera_source_time(22.5, 1.25), 21.25);
+ assert_eq!(camera_source_time(0.5, 1.25), 0.0);
+ }
+
+ // --- extra cameras (2-4) ----------------------------------------------------
+
+ /// Two clips: clip 0 has files for cameras 1 and 3 (camera 2's slot is empty), clip 1 for
+ /// camera 1 only. Layout regions: clip 0 shows cameras 1 and 2 on 2-5 s and camera 3 only
+ /// in an empty region; clip 1 shows camera 1 on 21-22 s.
+ fn layout_scene() -> Scene {
+ let layer = |camera: usize| {
+ format!(r#"{{"camera":{camera},"rect":{{"x":0,"y":0,"width":0.5,"height":0.5}},"radiusFrac":0,"shape":"rectangle","fillsFrame":false}}"#)
+ };
+ let json = format!(
+ r##"{{
+ "clips": [
+ {{"screenPath":"/s0.mp4","webcamPath":"/w0.mp4","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":true,
+ "additionalCameras":[{{"path":"/c1.mp4","offsetSec":0.5}},{{"path":"","offsetSec":0}},{{"path":"/c3.mp4","offsetSec":0}}]}},
+ {{"screenPath":"/s1.mp4","webcamPath":"/w1.mp4","sourceStartSec":20,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true,
+ "additionalCameras":[{{"path":"/d1.mp4","offsetSec":0}}]}}
+ ],
+ "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},
+ "effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},
+ "background":{{"kind":"color","color":"#000000"}},
+ "zoomRegions":[],
+ "cameraLayoutRegions":[
+ {{"clipIndex":0,"startSec":2,"endSec":5,"layers":[{l0},{l1},{l2}]}},
+ {{"clipIndex":0,"startSec":7,"endSec":7,"layers":[{l3}]}},
+ {{"clipIndex":1,"startSec":21,"endSec":22,"layers":[{l1}]}}
+ ],
+ "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},
+ "cropByClip":[null,null],
+ "output":{{"width":1920,"height":1080,"fps":30}}
+ }}"##,
+ l0 = layer(0),
+ l1 = layer(1),
+ l2 = layer(2),
+ l3 = layer(3),
+ );
+ Scene::from_json(&json).expect("layout scene")
+ }
+
+ fn camera(path: &str, offset_sec: f64) -> SceneClipCamera {
+ SceneClipCamera { path: path.to_string(), offset_sec }
+ }
+
+ #[test]
+ fn extra_cameras_to_open_are_those_the_clips_regions_reference() {
+ let scene = layout_scene();
+ // Camera 2 is shown but clip 0 has no file for it; camera 3 has a file but only an
+ // empty region; camera 0 is not an extra camera.
+ assert_eq!(extra_cameras_for_clip(&scene, 0), vec![1]);
+ // Clip 1 only sees its own region.
+ assert_eq!(extra_cameras_for_clip(&scene, 1), vec![1]);
+ assert!(extra_cameras_for_clip(&scene, 9).is_empty());
+ // No layout region at all: nothing to open, whatever files the clip has.
+ assert!(extra_cameras_for_clip(&multiclip_scene(), 0).is_empty());
+
+ let keys = scene_extra_camera_keys(&scene, 0);
+ assert_eq!(keys.len(), 1, "trailing empty slots are dropped");
+ let first = keys[0].as_ref().expect("camera 1");
+ assert_eq!((first.path.as_str(), first.offset_sec), ("/c1.mp4", 0.5));
+ // A shown camera after an unshown one keeps its index; nothing shown is an empty list.
+ let sources = [camera("/a.mp4", 0.0), camera("/b.mp4", 0.0), camera("/c.mp4", 0.0)];
+ let keys = extra_camera_keys(&[3], &sources);
+ assert_eq!(keys.len(), 3);
+ assert!(keys[0].is_none() && keys[1].is_none());
+ assert_eq!(keys[2].as_ref().map(|k| k.path.as_str()), Some("/c.mp4"));
+ assert!(extra_camera_keys(&[], &sources).is_empty());
+ assert!(extra_camera_keys(&[2], &[camera("/a.mp4", 0.0)]).is_empty());
+ }
+
+ #[test]
+ fn an_extra_camera_is_decoded_only_near_its_regions() {
+ let regions = scene_for_clip(&layout_scene(), 0).camera_layout_regions;
+ // From PREFETCH_LEAD_SEC before the region to its end.
+ assert!(!extra_camera_active(®ions, 1, 2.0 - PREFETCH_LEAD_SEC - 0.01));
+ assert!(extra_camera_active(®ions, 1, 2.0 - PREFETCH_LEAD_SEC));
+ assert!(extra_camera_active(®ions, 1, 3.0));
+ assert!(extra_camera_active(®ions, 1, 5.0));
+ assert!(!extra_camera_active(®ions, 1, 5.01));
+ // A camera the region does not show, and one only an empty region names.
+ assert!(!extra_camera_active(®ions, 3, 7.0));
+ assert!(!extra_camera_active(&[], 1, 3.0));
+ }
+
+ /// Playing on with the same files and region timing, a scene push does not seek the extra
+ /// cameras again; any change that matters, a jump, or a pause does.
+ #[test]
+ fn extra_cameras_are_sought_again_only_when_it_matters() {
+ assert!(!extras_need_seek(false, false, true));
+ assert!(extras_need_seek(true, false, true), "new files");
+ assert!(extras_need_seek(false, true, true), "regions moved");
+ assert!(extras_need_seek(false, false, false), "paused, or the position jumped");
+
+ let regions = scene_for_clip(&layout_scene(), 0).camera_layout_regions;
+ assert!(same_region_timing(®ions, ®ions.clone()));
+ // A moved rect is the same timing; a moved bound or another camera is not.
+ let mut moved_rect = regions.clone();
+ moved_rect[0].layers[0].rect.x += 0.1;
+ assert!(same_region_timing(®ions, &moved_rect));
+ let mut moved_end = regions.clone();
+ moved_end[0].end_sec += 0.5;
+ assert!(!same_region_timing(®ions, &moved_end));
+ let mut other_camera = regions.clone();
+ other_camera[0].layers[0].camera = 3;
+ assert!(!same_region_timing(®ions, &other_camera));
+ assert!(!same_region_timing(®ions, ®ions[..1]));
+ }
+
+ #[test]
+ fn a_missing_extra_camera_skips_its_layer() {
+ let failed: Result = Err(anyhow::anyhow!("0-byte file"));
+ assert_eq!(opened_or_skipped("/c1.mp4", failed), None);
+ assert_eq!(opened_or_skipped("/c1.mp4", Ok(7u8)), Some(7));
+
+ // Slot 0 failed to open, slot 1 is open with a frame, slot 2 is open with nothing to
+ // show (idle or past its end): only slot 1 hands a frame to the compositor.
+ let frame = 0x10usize as *const AVFrame;
+ let slots = [None, Some(frame), Some(std::ptr::null())];
+ let list = extra_frame_list(&slots, |f| *f);
+ assert_eq!(list, vec![std::ptr::null(), frame, std::ptr::null()]);
+ assert!(extra_frame_list::<*const AVFrame>(&[], |f| *f).is_empty());
+ }
+
+ #[test]
+ fn the_pool_key_includes_the_extra_cameras() {
+ let key = |extras: ExtraCameraKeys| ClipKey {
+ screen_path: "/s0.mp4".into(),
+ webcam_path: "/w0.mp4".into(),
+ webcam_offset_sec: 0.0,
+ extras,
+ };
+ let with_camera_1 = key(vec![Some(camera("/c1.mp4", 0.5))]);
+ assert!(with_camera_1.matches(&key(vec![Some(camera("/c1.mp4", 0.5))])));
+ // Same screen and camera 0, but camera 1 missing, another file or another offset.
+ assert!(!with_camera_1.matches(&key(Vec::new())));
+ assert!(!with_camera_1.matches(&key(vec![Some(camera("/other.mp4", 0.5))])));
+ assert!(!with_camera_1.matches(&key(vec![Some(camera("/c1.mp4", 0.75))])));
+ assert!(!with_camera_1.matches(&key(vec![None, Some(camera("/c1.mp4", 0.5))])));
+ assert!(key(Vec::new()).matches(&key(Vec::new())));
}
#[test]
diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs
index b118befdd..c31eb494f 100644
--- a/crates/compositor/src/pipeline_linux.rs
+++ b/crates/compositor/src/pipeline_linux.rs
@@ -54,8 +54,13 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
+ /// Cameras 2-4 of the clip (index k-1 = camera k, an empty `path` = none). Only those a
+ /// layout region of the clip shows are decoded.
+ pub additional_cameras: Vec,
}
+pub use crate::extra_cameras::ClipCamera;
+
/// Codec cible. Memes variantes que `pipeline_macos::ExportCodec`.
#[derive(Clone, Copy, Debug)]
pub enum ExportCodec {
@@ -127,6 +132,12 @@ impl Decoder {
self.decode_present(idx)
}
+ /// Contrat de `pipeline_windows::Decoder::seek_to_or_last`, que `seek_to` remplit déjà ici :
+ /// au-delà de la fin, `pump_to_target` tient la dernière image décodée.
+ pub unsafe fn seek_to_or_last(&mut self, seconds: f64) -> Result<*mut AVFrame> {
+ self.seek_to(seconds)
+ }
+
/// Decode la frame SEQUENTIELLE suivante — pompage `next_frame`, PAS de seek.
/// La frame rendue appartient au decodeur (valide jusqu'au prochain appel),
/// donc elle ne se libere pas ici, contrairement au chemin `decode_at`.
@@ -939,6 +950,7 @@ pub fn run_composited_multi(
let mut screen_decs: HashMap = HashMap::new();
let mut webcam_decs: HashMap = HashMap::new();
+ let mut extra_decs: HashMap = HashMap::new();
// ---- muxer MP4 (flux video + flux AAC) ----
let outc = CString::new(out)?;
@@ -1032,6 +1044,7 @@ pub fn run_composited_multi(
&scene,
&mut screen_decs,
&mut webcam_decs,
+ &mut extra_decs,
&mut |n| {
// Soumet la copie de la frame n SANS l'attendre et recolte la
// precedente : c'est tout le pipelining GPU. L'encodage, lui,
diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs
index 93827b00c..6fc75f01a 100644
--- a/crates/compositor/src/pipeline_macos.rs
+++ b/crates/compositor/src/pipeline_macos.rs
@@ -410,6 +410,25 @@ impl Decoder {
}
}
+ /// `seek_to`, mais une cible au-delà de la dernière image se pose sur cette dernière image au
+ /// lieu de ne rien rendre. L'audio d'un enregistrement dure souvent un peu plus que sa vidéo
+ /// (de 12 ms à près d'une demi-seconde mesurés) : son clip finit alors après la dernière
+ /// image, et y entrer par la fin, en scrubant de droite à gauche, demandait une image qui
+ /// n'existe pas. Le changement de clip échouait sans bruit, et la vue gardait la scène du
+ /// clip qu'on quittait.
+ pub unsafe fn seek_to_or_last(&mut self, seconds: f64) -> Result<*mut crate::ffi::AVFrame> {
+ let frame = self.seek_to(seconds)?;
+ if !frame.is_null() {
+ return Ok(frame);
+ }
+ // Le seek a décodé jusqu'à l'EOF : `cur_pts` est celui de la dernière image. L'EOF a vidé
+ // la frame courante, donc il faut le seek complet : `take` écarte le chemin rapide.
+ match self.cur_pts.take() {
+ Some(last) => self.seek_to(last as f64 * self.tb_sec()),
+ None => Ok(frame),
+ }
+ }
+
/// Déroule le décodeur en avant jusqu'à la première frame à `seconds` ou après, SANS
/// jeter son état. Symétrique de `pipeline_windows::Decoder::decode_forward_to`.
unsafe fn decode_forward_to(&mut self, seconds: f64, tb_sec: f64) -> Result<*mut crate::ffi::AVFrame> {
@@ -644,8 +663,13 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
+ /// Cameras 2-4 of the clip (index k-1 = camera k, an empty `path` = none). Only those a
+ /// layout region of the clip shows are decoded.
+ pub additional_cameras: Vec,
}
+pub use crate::extra_cameras::ClipCamera;
+
/// Codec cible pour l'export. Identique à `pipeline_windows::ExportCodec`.
pub enum ExportCodec {
H264,
@@ -1141,6 +1165,8 @@ pub fn run_composited_multi(
std::collections::HashMap::new();
let mut webcam_decs: std::collections::HashMap =
std::collections::HashMap::new();
+ let mut extra_decs: std::collections::HashMap =
+ std::collections::HashMap::new();
// ---- encodeur (candidat VT ou software) ----
let mut enc =
@@ -1222,6 +1248,7 @@ pub fn run_composited_multi(
&scene,
&mut screen_decs,
&mut webcam_decs,
+ &mut extra_decs,
&mut |n| {
enc.send_composited(comp, out_w, out_h, n as i64)?;
{
diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs
index fabe31207..8094fd44b 100644
--- a/crates/compositor/src/pipeline_windows.rs
+++ b/crates/compositor/src/pipeline_windows.rs
@@ -968,6 +968,25 @@ impl Decoder {
}
}
+ /// `seek_to`, mais une cible au-delà de la dernière image se pose sur cette dernière image au
+ /// lieu de ne rien rendre. L'audio d'un enregistrement dure souvent un peu plus que sa vidéo
+ /// (de 12 ms à près d'une demi-seconde mesurés) : son clip finit alors après la dernière
+ /// image, et y entrer par la fin, en scrubant de droite à gauche, demandait une image qui
+ /// n'existe pas. Le changement de clip échouait sans bruit, et la vue gardait la scène du
+ /// clip qu'on quittait.
+ pub(crate) unsafe fn seek_to_or_last(&mut self, seconds: f64) -> Result<*mut AVFrame> {
+ let frame = self.seek_to(seconds)?;
+ if !frame.is_null() {
+ return Ok(frame);
+ }
+ // Le seek a décodé jusqu'à l'EOF : `cur_pts` est celui de la dernière image. L'EOF a vidé
+ // la frame courante, donc il faut le seek complet : `take` écarte le chemin rapide.
+ match self.cur_pts.take() {
+ Some(last) => self.seek_to(last as f64 * self.tb_sec()),
+ None => Ok(frame),
+ }
+ }
+
/// Déroule le décodeur en avant jusqu'à la première frame à `seconds` ou après, SANS
/// jeter son état. Critère d'arrêt identique à celui du seek complet — c'est ce qui
/// garantit que les deux chemins rendent exactement la même frame.
@@ -1265,8 +1284,13 @@ pub struct ClipSource {
pub source_end_sec: f64,
pub webcam_offset_sec: f64,
pub has_audio: bool,
+ /// Cameras 2-4 of the clip (index k-1 = camera k, an empty `path` = none). Only those a
+ /// layout region of the clip shows are decoded.
+ pub additional_cameras: Vec,
}
+pub use crate::extra_cameras::ClipCamera;
+
/// Export **multiclip** : rend la timeline (clips ordonnés, avec trims) en un seul MP4.
/// Perf (contrainte §multiclip) : décodeurs ouverts une fois par source (cache) et réutilisés
/// entre clips du même asset ; **un seul seek keyframe par frontière de clip** ; décodage
@@ -1631,6 +1655,7 @@ unsafe fn run_multi_inner(
// pour deux &mut indépendants).
let mut screen_decs: HashMap = HashMap::new();
let mut webcam_decs: HashMap = HashMap::new();
+ let mut extra_decs: HashMap = HashMap::new();
// fps de sortie : choix explicite de l'app si fourni, sinon dérivé du 1er clip (recordings
// uniformes) — comportement historique.
@@ -1711,6 +1736,7 @@ unsafe fn run_multi_inner(
&scene,
&mut screen_decs,
&mut webcam_decs,
+ &mut extra_decs,
&mut |frame_index| {
// Backend CPU (WARP) : la frame composée descend en mémoire système via
// `send_composited` (le compositeur relit son NV12 interne vers un AVFrame
@@ -2836,6 +2862,29 @@ mod tests {
);
}
+ #[test]
+ fn a_seek_past_the_last_frame_can_land_on_it() {
+ let Some(gpu) = strict_hardware_gpu("a_seek_past_the_last_frame_can_land_on_it") else {
+ return;
+ };
+ let path = encode_color(&["-c:v", "libopenh264", "-b:v", "200k"], "last-frame.mp4");
+ let mut dec = unsafe { Decoder::open(path.to_str().expect("utf8 path"), &gpu) }
+ .unwrap_or_else(|e| panic!("H.264 Decoder::open: {e:#}"));
+
+ let frame = unsafe { dec.seek_to_or_last(10.0) }.expect("seek past the end");
+ assert!(!frame.is_null(), "a target past the end must land on the last frame");
+ assert!(!dec.cur_frame().is_null(), "the last frame must be presentable");
+ assert!(
+ unsafe { dec.next() }.expect("decode after the last frame").is_null(),
+ "the frame it landed on must be the last one"
+ );
+ assert!(
+ unsafe { dec.seek_to(10.0) }.expect("plain seek past the end").is_null(),
+ "seek_to itself still reports no frame past the end"
+ );
+ println!("CORE_ASSERTIONS_COMPLETED:a_seek_past_the_last_frame_can_land_on_it");
+ }
+
/// Playhead crossing clips is `Decoder::open` of the next source on the
/// same `Gpu` (#554). H.264 must stay on D3D11VA after an AV1 software
/// decoder has been opened and dropped.
diff --git a/crates/compositor/src/prism_mesh.rs b/crates/compositor/src/prism_mesh.rs
new file mode 100644
index 000000000..c045462b0
--- /dev/null
+++ b/crates/compositor/src/prism_mesh.rs
@@ -0,0 +1,876 @@
+//! Les deux curseurs de Prism Glow en MAILLAGE : les facettes tracées sur l'art 2D
+//! (`design/cursors/prism-glow/model`), taillées en cristal. Fichier GÉNÉRÉ par
+//! `design/cursors/prism-glow/model/export_compositor.py` : ne pas l'éditer à la main.
+//!
+//! Repère du modèle du mode 15 : unité = côté du sprite 128 px du thème, origine au hotspot,
+//! x à droite, y vers le bas, z vers la caméra, dessus du serti en z = 0. Chaque triangle du
+//! cristal tient en quatre float4 : (v0, drapeaux), (e1, r), (e2, g), (normale, b) ; drapeaux =
+//! paroi (1) + 2 × arêtes réelles (bit k : l'arête opposée au sommet k). Le fond plat, en
+//! z = -épaisseur, est laissé au shader. `POLYS` tient les silhouettes (bord extérieur du
+//! serti) puis les contours du cristal (son bord intérieur). `BOXES` range les triangles
+//! par voisinage : deux float4 par boîte, (coin bas, premier triangle), (coin haut, nombre).
+
+/// Un des deux modèles : ses triangles et sa silhouette dans les tables, sa boîte de dessin.
+pub struct PrismModel {
+ pub tri_start: usize,
+ pub tri_count: usize,
+ pub sil_start: usize,
+ pub sil_count: usize,
+ pub out_start: usize,
+ pub out_count: usize,
+ pub box_start: usize,
+ pub box_count: usize,
+ /// Largeur de la boîte, hauteur 1 (le sprite), en unités du modèle.
+ pub size: [f32; 2],
+ /// Hotspot, fraction de la boîte.
+ pub hotspot: [f32; 2],
+ /// Haut de la silhouette, fraction de la hauteur.
+ pub top: f32,
+ /// Sommet du cristal au-dessus de z = 0, et épaisseur sous z = 0.
+ pub height: f32,
+ pub thick: f32,
+}
+
+/// Arrondi des arêtes du serti, en unités du modèle (`PRISM_BEVEL` des shaders).
+pub const BEVEL: f32 = 0.012883;
+
+/// Flèche puis main, dans l'ordre des identifiants du shader.
+pub const MODELS: [PrismModel; 2] = [
+ PrismModel {
+ tri_start: 0,
+ tri_count: 31,
+ sil_start: 0,
+ sil_count: 25,
+ out_start: 67,
+ out_count: 9,
+ box_start: 0,
+ box_count: 4,
+ size: [0.63089, 1.0],
+ hotspot: [0.022658, 0.064647],
+ top: 0.0625,
+ height: 0.115951,
+ thick: 0.042945,
+ },
+ PrismModel {
+ tri_start: 31,
+ tri_count: 130,
+ sil_start: 25,
+ sil_count: 42,
+ out_start: 76,
+ out_count: 34,
+ box_start: 4,
+ box_count: 18,
+ size: [0.797658, 1.0],
+ hotspot: [0.343388, 0.063582],
+ top: 0.0625,
+ height: 0.0822,
+ thick: 0.043263,
+ },
+];
+
+pub const TRIS: [[f32; 4]; 644] = [
+ [0.252301, 0.266258, -0.042945, 11.0],
+ [-0.221166, -0.203988, 0.0, 0.01033],
+ [-0.221166, -0.203988, 0.042945, 0.045182],
+ [0.677984, -0.735077, 0.0, 0.577584],
+ [0.252301, 0.266258, -0.042945, 3.0],
+ [-0.221166, -0.203988, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [0.677984, -0.735077, 0.0, 0.577584],
+ [0.031135, 0.06227, -0.042945, 7.0],
+ [0.004294, 0.419785, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [-0.999948, 0.01023, 0.0, 0.577584],
+ [0.169632, 0.356442, 0.115951, 14.0],
+ [0.082669, -0.090184, -0.115951, 0.590616],
+ [-0.138497, -0.294172, -0.115951, 0.737911],
+ [0.466385, -0.505659, 0.725806, 0.973449],
+ [0.035429, 0.482055, 0.0, 14.0],
+ [0.134202, -0.125614, 0.115951, 0.000305],
+ [-0.004294, -0.419785, 0.0, 0.938689],
+ [-0.650191, 0.006652, 0.759742, 0.854994],
+ [0.031135, 0.06227, -0.042945, 9.0],
+ [0.004294, 0.419785, 0.0, 0.01033],
+ [0.004294, 0.419785, 0.042945, 0.045182],
+ [-0.999948, 0.01023, 0.0, 0.577584],
+ [0.169632, 0.356442, 0.115951, 14.0],
+ [0.159969, 0.080521, -0.045092, 0.381323],
+ [0.082669, -0.090184, -0.115951, 0.056123],
+ [0.461407, -0.510214, 0.725799, 0.973449],
+ [0.541104, 0.544325, -0.042945, 9.0],
+ [-0.288804, -0.278068, 0.0, 0.01033],
+ [-0.288804, -0.278068, 0.042945, 0.045182],
+ [0.69359, -0.72037, 0.0, 0.577584],
+ [0.329601, 0.436963, 0.070859, 14.0],
+ [0.211503, 0.107362, -0.070859, 0.545724],
+ [-0.077301, -0.170706, -0.070859, 0.064805],
+ [0.49567, -0.514808, 0.699488, 0.973449],
+ [0.268405, 0.533589, 0.081595, 14.0],
+ [0.061196, -0.096626, -0.010736, 0.000916],
+ [-0.098773, -0.177147, 0.034356, 0.046662],
+ [0.247834, 0.049458, 0.967539, 0.904662],
+ [0.541104, 0.544325, -0.042945, 7.0],
+ [-0.288804, -0.278068, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [0.69359, -0.72037, 0.0, 0.577584],
+ [0.199693, 0.595859, 0.0, 14.0],
+ [0.068712, -0.06227, 0.081595, 0.0],
+ [-0.030061, -0.239417, 0.115951, 0.01445],
+ [-0.504461, 0.426837, 0.750553, 0.254154],
+ [0.268405, 0.533589, 0.081595, 14.0],
+ [0.272699, 0.010736, -0.081595, 0.0],
+ [0.061196, -0.096626, -0.010736, 0.025193],
+ [0.283244, 0.073137, 0.956255, 0.527108],
+ [0.035429, 0.482055, 0.0, 14.0],
+ [0.0, 0.258742, 0.0, 0.0],
+ [0.134202, -0.125614, 0.115951, 0.473533],
+ [-0.653777, 0.0, 0.756687, 0.964691],
+ [0.359663, 0.559356, 0.0, 14.0],
+ [0.181442, -0.015031, 0.0, 0.006043],
+ [-0.091258, -0.025767, 0.081595, 0.076188],
+ [0.076465, 0.923048, 0.37701, 0.964691],
+ [0.035429, 0.482055, -0.042945, 3.0],
+ [0.0, 0.258742, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [-1.0, 0.0, 0.0, 0.577584],
+ [0.035429, 0.482055, -0.042945, 11.0],
+ [0.0, 0.258742, 0.0, 0.01033],
+ [0.0, 0.258742, 0.042945, 0.045182],
+ [-1.0, 0.0, 0.0, 0.577584],
+ [0.035429, 0.740798, -0.042945, 7.0],
+ [0.164264, -0.144939, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [0.661622, 0.749838, 0.0, 0.577584],
+ [0.035429, 0.740798, 0.0, 14.0],
+ [0.164264, -0.144939, 0.0, 0.0],
+ [0.134202, -0.384356, 0.115951, 0.010956],
+ [0.332572, 0.376915, 0.864483, 0.215854],
+ [0.035429, 0.740798, -0.042945, 11.0],
+ [0.164264, -0.144939, 0.0, 0.01033],
+ [0.164264, -0.144939, 0.042945, 0.045182],
+ [0.661622, 0.749838, 0.0, 0.577584],
+ [0.199693, 0.595859, -0.042945, 7.0],
+ [0.105215, 0.22546, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [-0.906183, 0.422886, 0.0, 0.577584],
+ [0.199693, 0.595859, 0.0, 14.0],
+ [0.105215, 0.22546, 0.0, 0.0],
+ [0.068712, -0.06227, 0.081595, 0.015991],
+ [-0.613882, 0.286478, 0.735581, 0.473533],
+ [0.199693, 0.595859, -0.042945, 11.0],
+ [0.105215, 0.22546, 0.0, 0.01033],
+ [0.105215, 0.22546, 0.042945, 0.045182],
+ [-0.906183, 0.422886, 0.0, 0.577584],
+ [0.304908, 0.821319, 0.0, 14.0],
+ [0.128834, -0.067638, 0.0, 0.0],
+ [-0.036503, -0.28773, 0.081595, 0.254154],
+ [0.133686, 0.254641, 0.957751, 0.973449],
+ [0.304908, 0.821319, -0.042945, 7.0],
+ [0.128834, -0.067638, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [0.464834, 0.885398, 0.0, 0.577584],
+ [0.433742, 0.753681, 0.0, 14.0],
+ [-0.07408, -0.194325, 0.0, 0.0],
+ [-0.165337, -0.220092, 0.081595, 0.964691],
+ [0.683362, -0.260508, 0.682021, 0.964691],
+ [0.433742, 0.753681, -0.042945, 11.0],
+ [-0.07408, -0.194325, 0.0, 0.01033],
+ [-0.07408, -0.194325, 0.042945, 0.045182],
+ [0.934406, -0.35621, 0.0, 0.577584],
+ [0.304908, 0.821319, -0.042945, 11.0],
+ [0.128834, -0.067638, 0.0, 0.01033],
+ [0.128834, -0.067638, 0.042945, 0.045182],
+ [0.464834, 0.885398, 0.0, 0.577584],
+ [0.433742, 0.753681, -0.042945, 7.0],
+ [-0.07408, -0.194325, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [0.934406, -0.35621, 0.0, 0.577584],
+ [0.359663, 0.559356, -0.042945, 7.0],
+ [0.181442, -0.015031, 0.042945, 0.01033],
+ [0.0, 0.0, 0.042945, 0.045182],
+ [0.082557, 0.996586, 0.0, 0.577584],
+ [0.359663, 0.559356, -0.042945, 11.0],
+ [0.181442, -0.015031, 0.0, 0.01033],
+ [0.181442, -0.015031, 0.042945, 0.045182],
+ [0.082557, 0.996586, 0.0, 0.577584],
+ [0.044345, 0.043263, -0.043263, 11.0],
+ [-0.043263, -0.005408, 0.0, 0.01033],
+ [-0.043263, -0.005408, 0.043263, 0.045182],
+ [0.124035, -0.992278, 0.0, 0.577584],
+ [0.044345, 0.043263, -0.043263, 7.0],
+ [-0.043263, -0.005408, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.124035, -0.992278, 0.0, 0.577584],
+ [0.001082, 0.037855, -0.043263, 7.0],
+ [-0.044345, 0.040019, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.669964, -0.742393, 0.0, 0.577584],
+ [0.044345, 0.043263, 0.0, 10.0],
+ [-0.043263, -0.005408, 0.0, 0.000305],
+ [-0.040019, 0.030284, 0.049753, 0.964691],
+ [0.101434, -0.811469, 0.575525, 0.973449],
+ [0.004326, 0.073548, 0.049753, 14.0],
+ [-0.003245, -0.035692, -0.049753, 0.0],
+ [-0.04759, 0.004326, -0.049753, 0.955978],
+ [-0.580475, -0.643229, 0.499304, 0.896269],
+ [0.001082, 0.037855, -0.043263, 11.0],
+ [-0.044345, 0.040019, 0.0, 0.01033],
+ [-0.044345, 0.040019, 0.043263, 0.045182],
+ [-0.669964, -0.742393, 0.0, 0.577584],
+ [-0.031366, 0.229295, 0.058405, 14.0],
+ [0.035692, -0.155748, -0.008653, 0.000305],
+ [-0.011897, -0.151422, -0.058405, 0.955978],
+ [-0.716533, -0.20131, 0.667874, 0.904662],
+ [-0.043263, 0.077874, -0.043263, 7.0],
+ [-0.001082, 0.154666, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.999976, -0.006993, 0.0, 0.577584],
+ [-0.031366, 0.229295, 0.058405, 14.0],
+ [0.089771, -0.137361, -0.02055, 0.445197],
+ [0.035692, -0.155748, -0.008653, 0.973449],
+ [0.216369, -0.004654, 0.976301, 0.973449],
+ [-0.031366, 0.229295, 0.058405, 14.0],
+ [-0.011897, -0.151422, -0.058405, 0.000305],
+ [-0.012979, 0.003245, -0.058405, 0.955978],
+ [-0.976245, -0.006827, 0.216564, 0.904662],
+ [-0.043263, 0.077874, -0.043263, 9.0],
+ [-0.001082, 0.154666, 0.0, 0.01033],
+ [-0.001082, 0.154666, 0.043263, 0.045182],
+ [-0.999976, -0.006993, 0.0, 0.577584],
+ [-0.045426, 0.564586, 0.052998, 14.0],
+ [0.014061, -0.335291, 0.005408, 0.520989],
+ [0.001082, -0.332046, -0.052998, 0.088655],
+ [-0.975941, -0.037462, 0.214794, 0.973449],
+ [-0.044345, 0.23254, 0.0, 3.0],
+ [0.0, 0.0, -0.043263, 0.01033],
+ [-0.001082, 0.332046, -0.043263, 0.045182],
+ [-0.999995, -0.003257, 0.0, 0.577584],
+ [-0.163319, 0.380717, -0.043263, 7.0],
+ [-0.060569, 0.02704, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.407651, -0.913138, 0.0, 0.577584],
+ [-0.094098, 0.431551, -0.043263, 11.0],
+ [-0.069221, -0.050834, 0.0, 0.01033],
+ [-0.069221, -0.050834, 0.043263, 0.045182],
+ [0.591909, -0.806004, 0.0, 0.577584],
+ [-0.163319, 0.380717, -0.043263, 11.0],
+ [-0.060569, 0.02704, 0.0, 0.01033],
+ [-0.060569, 0.02704, 0.043263, 0.045182],
+ [-0.407651, -0.913138, 0.0, 0.577584],
+ [0.075711, 0.08869, -0.043263, 11.0],
+ [-0.031366, -0.045426, 0.0, 0.01033],
+ [-0.031366, -0.045426, 0.043263, 0.045182],
+ [0.822897, -0.568191, 0.0, 0.577584],
+ [0.044345, 0.043263, 0.0, 6.0],
+ [-0.040019, 0.030284, 0.049753, 0.000305],
+ [0.014061, 0.048671, 0.037855, 0.964691],
+ [0.365607, -0.634949, 0.680566, 0.973449],
+ [0.075711, 0.08869, -0.043263, 7.0],
+ [-0.031366, -0.045426, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.822897, -0.568191, 0.0, 0.577584],
+ [0.058405, 0.091934, 0.037855, 14.0],
+ [0.017305, -0.003245, -0.037855, 0.0],
+ [-0.014061, -0.048671, -0.037855, 0.473533],
+ [0.757369, -0.522945, 0.39105, 0.904662],
+ [-0.031366, 0.229295, 0.058405, 14.0],
+ [0.107077, -0.140606, -0.058405, 0.0],
+ [0.089771, -0.137361, -0.02055, 0.376257],
+ [0.812052, 0.481346, 0.329966, 0.830777],
+ [0.077874, 0.407756, -0.043263, 11.0],
+ [-0.002163, -0.319067, 0.0, 0.01033],
+ [-0.002163, -0.319067, 0.043263, 0.045182],
+ [0.999977, -0.006779, 0.0, 0.577584],
+ [-0.031366, 0.229295, 0.058405, 14.0],
+ [0.10924, 0.178461, -0.058405, 0.0],
+ [0.107077, -0.140606, -0.058405, 0.376257],
+ [0.475583, -0.003224, 0.879665, 0.830777],
+ [0.147095, 0.272559, -0.043263, 7.0],
+ [-0.035692, 0.034611, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.696146, -0.7179, 0.0, 0.577584],
+ [0.147095, 0.272559, -0.043263, 11.0],
+ [-0.035692, 0.034611, 0.0, 0.01033],
+ [-0.035692, 0.034611, 0.043263, 0.045182],
+ [-0.696146, -0.7179, 0.0, 0.577584],
+ [0.144932, 0.307169, 0.049753, 14.0],
+ [0.002163, -0.034611, -0.049753, 0.0],
+ [-0.033529, 0.0, -0.049753, 0.9131],
+ [-0.630237, -0.649931, 0.424725, 0.904662],
+ [0.183869, 0.317985, 0.037855, 6.0],
+ [-0.036774, -0.045426, -0.037855, 0.0],
+ [-0.038937, -0.010816, 0.011897, 0.806958],
+ [0.374433, -0.753477, 0.540438, 0.854994],
+ [0.077874, 0.407756, -0.043263, 7.0],
+ [-0.002163, -0.319067, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.999977, -0.006779, 0.0, 0.577584],
+ [0.111403, 0.307169, -0.043263, 7.0],
+ [-0.002163, 0.131953, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.999866, -0.016391, 0.0, 0.577584],
+ [0.10924, 0.439122, 0.0, 6.0],
+ [0.035692, -0.131953, 0.049753, 0.18117],
+ [0.002163, -0.131953, 0.0, 0.955978],
+ [-0.82919, -0.013593, 0.558802, 0.964691],
+ [0.128708, 0.463999, 0.063813, 14.0],
+ [0.055161, -0.146014, -0.025958, 0.246204],
+ [0.016224, -0.156829, -0.014061, 0.964691],
+ [0.305397, -0.053644, 0.950713, 0.973449],
+ [0.111403, 0.307169, -0.043263, 11.0],
+ [-0.002163, 0.131953, 0.0, 0.01033],
+ [-0.002163, 0.131953, 0.043263, 0.045182],
+ [-0.999866, -0.016391, 0.0, 0.577584],
+ [0.189277, 0.28013, -0.043263, 11.0],
+ [-0.042182, -0.007571, 0.0, 0.01033],
+ [-0.042182, -0.007571, 0.043263, 0.045182],
+ [0.176664, -0.984271, 0.0, 0.577584],
+ [0.183869, 0.317985, 0.037855, 10.0],
+ [0.005408, -0.037855, -0.037855, 0.0],
+ [-0.036774, -0.045426, -0.037855, 0.806958],
+ [0.124328, -0.692684, 0.710445, 0.854994],
+ [0.189277, 0.28013, -0.043263, 7.0],
+ [-0.042182, -0.007571, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.176664, -0.984271, 0.0, 0.577584],
+ [0.183869, 0.317985, 0.037855, 14.0],
+ [0.02704, -0.004326, -0.037855, 0.0],
+ [0.005408, -0.037855, -0.037855, 0.514916],
+ [0.70062, -0.452013, 0.552101, 0.846876],
+ [0.210908, 0.313659, -0.043263, 11.0],
+ [-0.021632, -0.033529, 0.0, 0.01033],
+ [-0.021632, -0.033529, 0.043263, 0.045182],
+ [0.840297, -0.542127, 0.0, 0.577584],
+ [0.210908, 0.445612, 0.0, 10.0],
+ [0.0, -0.131953, 0.0, 0.0],
+ [-0.02704, -0.127627, 0.037855, 0.439658],
+ [0.813734, 0.0, 0.581238, 0.871366],
+ [0.210908, 0.313659, -0.043263, 7.0],
+ [-0.021632, -0.033529, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.840297, -0.542127, 0.0, 0.577584],
+ [0.210908, 0.445612, -0.043263, 11.0],
+ [0.0, -0.131953, 0.0, 0.01033],
+ [0.0, -0.131953, 0.043263, 0.045182],
+ [1.0, 0.0, 0.0, 0.577584],
+ [0.241193, 0.343943, -0.043263, 7.0],
+ [-0.002163, 0.107077, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.999796, -0.020198, 0.0, 0.577584],
+ [0.276885, 0.311496, -0.043263, 11.0],
+ [-0.035692, 0.032447, 0.0, 0.01033],
+ [-0.035692, 0.032447, 0.043263, 0.045182],
+ [-0.672673, -0.73994, 0.0, 0.577584],
+ [0.23903, 0.45102, 0.0, 14.0],
+ [0.042182, -0.108158, 0.046508, 0.000305],
+ [0.002163, -0.107077, 0.0, 0.846876],
+ [-0.758098, -0.015315, 0.651961, 0.896269],
+ [0.276885, 0.311496, -0.043263, 7.0],
+ [-0.035692, 0.032447, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.672673, -0.73994, 0.0, 0.577584],
+ [0.281211, 0.342862, 0.046508, 14.0],
+ [-0.004326, -0.031366, -0.046508, 0.0],
+ [-0.040019, 0.001082, -0.046508, 0.752941],
+ [-0.58651, -0.645161, 0.489667, 0.846876],
+ [0.246601, 0.459672, 0.06165, 14.0],
+ [0.070303, -0.101669, -0.024876, 0.262257],
+ [0.034611, -0.116811, -0.015142, 0.973449],
+ [0.27928, -0.041605, 0.959308, 0.973449],
+ [0.319067, 0.319067, -0.043263, 11.0],
+ [-0.042182, -0.007571, 0.0, 0.01033],
+ [-0.042182, -0.007571, 0.043263, 0.045182],
+ [0.176664, -0.984271, 0.0, 0.577584],
+ [0.319067, 0.319067, 0.0, 10.0],
+ [-0.042182, -0.007571, 0.0, 0.0],
+ [-0.037855, 0.023795, 0.046508, 0.896269],
+ [0.1483, -0.826245, 0.54344, 0.896269],
+ [0.319067, 0.319067, -0.043263, 7.0],
+ [-0.042182, -0.007571, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.176664, -0.984271, 0.0, 0.577584],
+ [0.319067, 0.319067, 0.0, 6.0],
+ [-0.037855, 0.023795, 0.046508, 0.0],
+ [-0.002163, 0.038937, 0.036774, 0.896269],
+ [0.437906, -0.604311, 0.665618, 0.896269],
+ [0.316904, 0.358004, 0.036774, 14.0],
+ [0.024876, -0.004326, -0.036774, 0.0],
+ [0.002163, -0.038937, -0.036774, 0.617212],
+ [0.707338, -0.464191, 0.533104, 0.921584],
+ [0.34178, 0.353677, -0.043263, 11.0],
+ [-0.022713, -0.034611, 0.0, 0.01033],
+ [-0.022713, -0.034611, 0.043263, 0.045182],
+ [0.836048, -0.548657, 0.0, 0.577584],
+ [0.340698, 0.474815, 0.0, 14.0],
+ [0.001082, -0.121137, 0.0, 0.0],
+ [-0.023795, -0.116811, 0.036774, 0.473533],
+ [0.828664, 0.007399, 0.559697, 0.930114],
+ [0.34178, 0.353677, -0.043263, 7.0],
+ [-0.022713, -0.034611, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.836048, -0.548657, 0.0, 0.577584],
+ [0.340698, 0.474815, -0.043263, 11.0],
+ [0.001082, -0.121137, 0.0, 0.01033],
+ [0.001082, -0.121137, 0.043263, 0.045182],
+ [0.99996, 0.008928, 0.0, 0.577584],
+ [0.411001, 0.351514, -0.043263, 11.0],
+ [-0.034611, 0.030284, 0.0, 0.01033],
+ [-0.034611, 0.030284, 0.043263, 0.045182],
+ [-0.658505, -0.752577, 0.0, 0.577584],
+ [0.411001, 0.351514, -0.043263, 7.0],
+ [-0.034611, 0.030284, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.658505, -0.752577, 0.0, 0.577584],
+ [0.412083, 0.38288, 0.043263, 14.0],
+ [-0.001082, -0.031366, -0.043263, 0.0],
+ [-0.035692, -0.001082, -0.043263, 0.745403],
+ [-0.574039, -0.656045, 0.489984, 0.854994],
+ [0.45102, 0.359085, -0.043263, 11.0],
+ [-0.040019, -0.007571, 0.0, 0.01033],
+ [-0.040019, -0.007571, 0.043263, 0.045182],
+ [0.185892, -0.98257, 0.0, 0.577584],
+ [0.45102, 0.359085, 0.0, 10.0],
+ [-0.040019, -0.007571, 0.0, 0.000305],
+ [-0.038937, 0.023795, 0.043263, 0.879622],
+ [0.151736, -0.802035, 0.577682, 0.9131],
+ [0.45102, 0.359085, -0.043263, 7.0],
+ [-0.040019, -0.007571, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.185892, -0.98257, 0.0, 0.577584],
+ [0.45102, 0.359085, 0.0, 6.0],
+ [-0.038937, 0.023795, 0.043263, 0.000305],
+ [0.003245, 0.0411, 0.032447, 0.879622],
+ [0.417852, -0.583049, 0.696744, 0.9131],
+ [0.473733, 0.391533, -0.043263, 11.0],
+ [-0.022713, -0.032447, 0.0, 0.01033],
+ [-0.022713, -0.032447, 0.043263, 0.045182],
+ [0.819232, -0.573462, 0.0, 0.577584],
+ [0.454264, 0.400185, 0.032447, 14.0],
+ [0.019468, -0.008653, -0.032447, 0.0],
+ [-0.003245, -0.0411, -0.032447, 0.527108],
+ [0.688617, -0.482032, 0.541712, 0.887922],
+ [0.473733, 0.391533, -0.043263, 7.0],
+ [-0.022713, -0.032447, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.819232, -0.573462, 0.0, 0.577584],
+ [-0.223888, 0.407756, -0.043263, 7.0],
+ [0.002163, 0.06814, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.999496, 0.03173, 0.0, 0.577584],
+ [-0.223888, 0.407756, -0.043263, 11.0],
+ [0.002163, 0.06814, 0.0, 0.01033],
+ [0.002163, 0.06814, 0.043263, 0.045182],
+ [-0.999496, 0.03173, 0.0, 0.577584],
+ [-0.167645, 0.416409, 0.046508, 14.0],
+ [-0.056242, -0.008653, -0.046508, 0.000305],
+ [-0.054079, 0.059487, -0.046508, 0.955978],
+ [-0.638985, 0.020285, 0.768952, 0.973449],
+ [-0.167645, 0.416409, 0.046508, 14.0],
+ [0.004326, -0.035692, -0.046508, 0.000305],
+ [-0.056242, -0.008653, -0.046508, 0.955978],
+ [-0.339782, -0.761112, 0.552501, 0.973449],
+ [-0.221724, 0.475896, 0.0, 14.0],
+ [0.139524, 0.055161, 0.043263, 0.520989],
+ [0.054079, -0.059487, 0.046508, 0.973449],
+ [-0.39307, 0.317373, 0.863001, 0.973449],
+ [-0.167645, 0.416409, 0.046508, 6.0],
+ [0.073548, 0.015142, -0.046508, 0.0],
+ [0.004326, -0.035692, -0.046508, 0.768154],
+ [0.490917, -0.668482, 0.558688, 0.871366],
+ [-0.094098, 0.431551, -0.043263, 7.0],
+ [-0.069221, -0.050834, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.591909, -0.806004, 0.0, 0.577584],
+ [-0.167645, 0.416409, 0.046508, 10.0],
+ [0.085445, 0.114648, -0.003245, 0.0],
+ [0.073548, 0.015142, -0.046508, 0.768154],
+ [0.548356, -0.38771, 0.740936, 0.871366],
+ [-0.0822, 0.531057, -0.043263, 11.0],
+ [-0.011897, -0.099506, 0.0, 0.01033],
+ [-0.011897, -0.099506, 0.043263, 0.045182],
+ [0.992928, -0.11872, 0.0, 0.577584],
+ [-0.044345, 0.23254, 0.0, 7.0],
+ [-0.001082, 0.332046, -0.043263, 0.01033],
+ [-0.001082, 0.332046, 0.052998, 0.045182],
+ [-0.999995, -0.003257, 0.0, 0.577584],
+ [-0.045426, 0.564586, 0.052998, 14.0],
+ [0.1233, -0.156829, -0.052998, 0.520989],
+ [0.014061, -0.335291, 0.005408, 0.088655],
+ [0.429355, 0.032562, 0.902549, 0.973449],
+ [0.10924, 0.439122, 0.0, 14.0],
+ [-0.031366, -0.031366, 0.0, 0.132876],
+ [-0.154666, 0.125464, 0.052998, 0.028428],
+ [0.182761, -0.182761, 0.966021, 0.955978],
+ [0.10924, 0.439122, -0.043263, 11.0],
+ [-0.031366, -0.031366, 0.0, 0.01033],
+ [-0.031366, -0.031366, 0.043263, 0.045182],
+ [0.707107, -0.707107, 0.0, 0.577584],
+ [0.10924, 0.439122, -0.043263, 7.0],
+ [-0.031366, -0.031366, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.707107, -0.707107, 0.0, 0.577584],
+ [0.10924, 0.439122, 0.0, 10.0],
+ [0.019468, 0.024876, 0.063813, 0.18117],
+ [0.035692, -0.131953, 0.049753, 0.955978],
+ [-0.933936, -0.126584, 0.334276, 0.964691],
+ [0.128708, 0.463999, 0.063813, 14.0],
+ [0.050834, 0.002163, -0.005408, 0.0],
+ [0.0822, -0.018387, -0.063813, 0.439658],
+ [0.07859, -0.92643, 0.368172, 0.871366],
+ [-0.221724, 0.475896, -0.043263, 7.0],
+ [0.122219, 0.207664, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.861819, 0.507216, 0.0, 0.577584],
+ [-0.221724, 0.475896, -0.043263, 9.0],
+ [0.122219, 0.207664, 0.0, 0.01033],
+ [0.122219, 0.207664, 0.043263, 0.045182],
+ [-0.861819, 0.507216, 0.0, 0.577584],
+ [-0.099506, 0.68356, 0.0, 14.0],
+ [0.017305, -0.152503, 0.043263, 0.0],
+ [-0.122219, -0.207664, 0.0, 0.723049],
+ [-0.365879, 0.215335, 0.905408, 0.854994],
+ [-0.0822, 0.531057, -0.043263, 7.0],
+ [-0.011897, -0.099506, 0.043263, 0.01033],
+ [0.0, 0.0, 0.086527, 0.045182],
+ [0.992928, -0.11872, 0.0, 0.577584],
+ [-0.099506, 0.68356, 0.0, 14.0],
+ [0.054079, -0.118974, 0.052998, 0.0],
+ [0.017305, -0.152503, 0.043263, 0.514916],
+ [-0.41958, 0.203352, 0.884647, 0.863157],
+ [-0.099506, 0.68356, -0.043263, 3.0],
+ [0.086527, 0.149258, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.86514, 0.501531, 0.0, 0.577584],
+ [-0.045426, 0.564586, -0.043263, 11.0],
+ [-0.036774, -0.033529, 0.0, 0.01033],
+ [-0.036774, -0.033529, 0.086527, 0.045182],
+ [0.673754, -0.738956, 0.0, 0.577584],
+ [-0.045426, 0.564586, -0.043263, 7.0],
+ [-0.036774, -0.033529, 0.086527, 0.01033],
+ [0.0, 0.0, 0.096261, 0.045182],
+ [0.673754, -0.738956, 0.0, 0.577584],
+ [-0.099506, 0.68356, 0.0, 14.0],
+ [0.086527, 0.149258, 0.0, 0.0],
+ [0.054079, -0.118974, 0.052998, 0.262257],
+ [-0.385563, 0.223515, 0.8952, 0.9131],
+ [-0.099506, 0.68356, -0.043263, 11.0],
+ [0.086527, 0.149258, 0.0, 0.01033],
+ [0.086527, 0.149258, 0.043263, 0.045182],
+ [-0.86514, 0.501531, 0.0, 0.577584],
+ [-0.012979, 0.832818, 0.0, 14.0],
+ [0.214153, -0.194685, 0.0822, 0.000305],
+ [-0.032447, -0.268232, 0.052998, 0.930114],
+ [-0.176863, 0.211325, 0.961281, 0.973449],
+ [0.128708, 0.463999, 0.063813, 14.0],
+ [-0.019468, -0.024876, -0.063813, 0.168276],
+ [-0.174135, 0.100587, -0.010816, 0.038209],
+ [-0.469238, -0.764881, 0.441331, 0.955978],
+ [0.201174, 0.638133, 0.0822, 14.0],
+ [-0.072466, -0.174135, -0.018387, 0.18117],
+ [-0.246601, -0.073548, -0.029203, 0.082277],
+ [-0.098561, -0.063844, 0.993081, 0.973449],
+ [-0.012979, 0.832818, -0.043263, 7.0],
+ [0.339617, -0.002163, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.006369, 0.99998, 0.0, 0.577584],
+ [0.179543, 0.466162, 0.058405, 10.0],
+ [-0.050834, -0.002163, 0.005408, 0.000305],
+ [0.021632, 0.171972, 0.023795, 0.022171],
+ [0.110896, -0.149888, 0.982464, 0.381323],
+ [-0.012979, 0.832818, 0.0, 14.0],
+ [0.339617, -0.002163, 0.0, 0.0],
+ [0.214153, -0.194685, 0.0822, 0.027314],
+ [0.002492, 0.391296, 0.920261, 0.49102],
+ [0.183869, 0.317985, 0.037855, 10.0],
+ [-0.055161, 0.146014, 0.025958, 0.0],
+ [0.02704, 0.127627, -0.037855, 0.439658],
+ [0.623841, 0.097823, 0.775405, 0.871366],
+ [0.210908, 0.445612, -0.043263, 7.0],
+ [0.0, -0.131953, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [1.0, 0.0, 0.0, 0.577584],
+ [0.210908, 0.445612, 0.0, 10.0],
+ [-0.031366, 0.02055, 0.058405, 0.000305],
+ [0.035692, 0.014061, 0.06165, 0.022171],
+ [-0.105864, -0.954447, 0.278971, 0.381323],
+ [0.23903, 0.45102, -0.043263, 11.0],
+ [-0.028121, -0.005408, 0.0, 0.01033],
+ [-0.028121, -0.005408, 0.043263, 0.045182],
+ [0.188847, -0.982006, 0.0, 0.577584],
+ [0.23903, 0.45102, -0.043263, 7.0],
+ [-0.028121, -0.005408, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.188847, -0.982006, 0.0, 0.577584],
+ [0.246601, 0.459672, 0.06165, 11.0],
+ [-0.007571, -0.008653, -0.06165, 0.000305],
+ [-0.035692, -0.014061, -0.06165, 0.022171],
+ [0.187618, -0.975617, 0.113888, 0.381323],
+ [0.241193, 0.343943, -0.043263, 11.0],
+ [-0.002163, 0.107077, 0.0, 0.01033],
+ [-0.002163, 0.107077, 0.043263, 0.045182],
+ [-0.999796, -0.020198, 0.0, 0.577584],
+ [0.23903, 0.45102, 0.0, 15.0],
+ [0.007571, 0.008653, 0.06165, 0.000305],
+ [0.042182, -0.108158, 0.046508, 0.846876],
+ [-0.94107, -0.299262, 0.157572, 0.896269],
+ [0.316904, 0.358004, 0.036774, 14.0],
+ [-0.070303, 0.101669, 0.024876, 0.0],
+ [0.023795, 0.116811, -0.036774, 0.473533],
+ [0.523424, 0.157027, 0.837478, 0.930114],
+ [0.340698, 0.474815, -0.043263, 7.0],
+ [0.001082, -0.121137, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.99996, 0.008928, 0.0, 0.577584],
+ [0.376391, 0.381799, -0.043263, 11.0],
+ [-0.001082, 0.107077, 0.0, 0.01033],
+ [-0.001082, 0.107077, 0.043263, 0.045182],
+ [-0.999949, -0.0101, 0.0, 0.577584],
+ [0.376391, 0.381799, -0.043263, 7.0],
+ [-0.001082, 0.107077, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [-0.999949, -0.0101, 0.0, 0.577584],
+ [0.375309, 0.488875, 0.0, 14.0],
+ [0.036774, -0.105995, 0.043263, 0.000305],
+ [0.001082, -0.107077, 0.0, 0.879622],
+ [-0.771254, -0.00779, 0.63648, 0.896269],
+ [0.375309, 0.488875, 0.0, 14.0],
+ [0.078955, -0.08869, 0.032447, 0.223224],
+ [0.036774, -0.105995, 0.043263, 0.964691],
+ [0.071225, 0.39802, 0.914608, 0.973449],
+ [0.473733, 0.603523, 0.0, 14.0],
+ [0.0, -0.21199, 0.0, 0.158953],
+ [-0.019468, -0.203337, 0.032447, 0.030716],
+ [0.857493, 0.0, 0.514496, 0.964691],
+ [0.473733, 0.603523, -0.043263, 11.0],
+ [0.0, -0.21199, 0.0, 0.01033],
+ [0.0, -0.21199, 0.043263, 0.045182],
+ [1.0, 0.0, 0.0, 0.577584],
+ [0.276885, 0.484549, 0.058405, 10.0],
+ [-0.030284, -0.024876, 0.003245, 0.000305],
+ [-0.097342, -0.018387, 0.0, 0.022171],
+ [-0.03153, 0.166923, 0.985466, 0.381323],
+ [0.246601, 0.459672, 0.06165, 14.0],
+ [0.030284, 0.024876, -0.003245, 0.0],
+ [0.094098, 0.015142, -0.06165, 0.473533],
+ [0.518869, -0.545855, 0.657888, 0.930114],
+ [0.375309, 0.488875, -0.043263, 11.0],
+ [-0.034611, -0.014061, 0.0, 0.01033],
+ [-0.034611, -0.014061, 0.043263, 0.045182],
+ [0.376377, -0.926467, 0.0, 0.577584],
+ [0.340698, 0.474815, 0.0, 14.0],
+ [-0.063813, 0.009734, 0.058405, 0.000305],
+ [0.034611, 0.014061, 0.0, 0.022171],
+ [0.3276, -0.806402, 0.492334, 0.381323],
+ [0.375309, 0.488875, -0.043263, 7.0],
+ [-0.034611, -0.014061, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.376377, -0.926467, 0.0, 0.577584],
+ [0.454264, 0.400185, 0.032447, 12.0],
+ [-0.078955, 0.08869, -0.032447, 0.158953],
+ [0.019468, 0.203337, -0.032447, 0.030716],
+ [-0.201684, 0.173144, 0.964025, 0.964691],
+ [0.201174, 0.638133, 0.0822, 8.0],
+ [0.075711, -0.153585, -0.023795, 0.000305],
+ [-0.021632, -0.171972, -0.023795, 0.022171],
+ [0.026497, -0.140281, 0.989757, 0.381323],
+ [0.473733, 0.603523, -0.043263, 7.0],
+ [0.0, -0.21199, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [1.0, 0.0, 0.0, 0.577584],
+ [0.276885, 0.484549, 0.058405, 14.0],
+ [-0.075711, 0.153585, 0.023795, 0.000305],
+ [0.098424, 0.004326, -0.058405, 0.022171],
+ [0.50316, 0.115345, 0.856461, 0.381323],
+ [0.375309, 0.488875, 0.0, 10.0],
+ [0.090853, 0.12979, 0.0, 0.158953],
+ [0.098424, 0.114648, 0.0, 0.030716],
+ [0.0, 0.0, 1.0, 0.964691],
+ [0.466162, 0.618665, -0.043263, 11.0],
+ [0.007571, -0.015142, 0.0, 0.01033],
+ [0.007571, -0.015142, 0.043263, 0.045182],
+ [0.894427, 0.447214, 0.0, 0.577584],
+ [0.466162, 0.618665, -0.043263, 3.0],
+ [0.007571, -0.015142, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.894427, 0.447214, 0.0, 0.577584],
+ [0.326638, 0.830655, 0.0, 14.0],
+ [0.139524, -0.21199, 0.0, 0.0],
+ [0.048671, -0.34178, 0.0, 0.015213],
+ [0.0, 0.0, 1.0, 0.428687],
+ [0.326638, 0.830655, 0.0, 14.0],
+ [0.048671, -0.34178, 0.0, 0.007492],
+ [-0.125464, -0.192522, 0.0822, 0.054475],
+ [0.472493, 0.067285, 0.878762, 0.964691],
+ [0.326638, 0.830655, -0.043263, 9.0],
+ [0.139524, -0.21199, 0.0, 0.01033],
+ [0.139524, -0.21199, 0.043263, 0.045182],
+ [0.835314, 0.549773, 0.0, 0.577584],
+ [0.326638, 0.830655, -0.043263, 7.0],
+ [0.139524, -0.21199, 0.043263, 0.01033],
+ [0.0, 0.0, 0.043263, 0.045182],
+ [0.835314, 0.549773, 0.0, 0.577584],
+ [-0.012979, 0.832818, -0.043263, 11.0],
+ [0.339617, -0.002163, 0.0, 0.01033],
+ [0.339617, -0.002163, 0.043263, 0.045182],
+ [0.006369, 0.99998, 0.0, 0.577584],
+];
+
+pub const POLYS: [[f32; 2]; 110] = [
+ [0.006442, -0.002147],
+ [-0.001074, 0.001074],
+ [-0.004294, 0.007515],
+ [-0.004294, 0.806288],
+ [-0.001074, 0.809509],
+ [0.004294, 0.809509],
+ [0.022546, 0.796626],
+ [0.185736, 0.655982],
+ [0.281288, 0.86319],
+ [0.286656, 0.870706],
+ [0.293098, 0.871779],
+ [0.465951, 0.781595],
+ [0.478834, 0.773006],
+ [0.478834, 0.763344],
+ [0.407975, 0.592638],
+ [0.45092, 0.586196],
+ [0.601227, 0.573313],
+ [0.606595, 0.569018],
+ [0.606595, 0.564724],
+ [0.601227, 0.556135],
+ [0.50138, 0.453067],
+ [0.425153, 0.37684],
+ [0.289877, 0.246933],
+ [0.081595, 0.057975],
+ [0.013957, 0.0],
+ [-0.011897, 0.0],
+ [-0.055161, 0.037855],
+ [-0.081119, 0.063813],
+ [-0.083282, 0.06814],
+ [-0.084363, 0.38288],
+ [-0.089771, 0.38288],
+ [-0.158993, 0.33529],
+ [-0.166564, 0.336372],
+ [-0.250927, 0.379635],
+ [-0.261743, 0.388288],
+ [-0.263906, 0.461836],
+ [-0.262824, 0.486712],
+ [-0.173053, 0.638133],
+ [-0.044345, 0.860939],
+ [-0.036774, 0.867429],
+ [-0.024876, 0.871755],
+ [0.33529, 0.871755],
+ [0.349351, 0.867429],
+ [0.363412, 0.853368],
+ [0.513752, 0.619747],
+ [0.513752, 0.379635],
+ [0.466162, 0.322311],
+ [0.461836, 0.320148],
+ [0.399104, 0.311496],
+ [0.393696, 0.313659],
+ [0.374227, 0.332046],
+ [0.343943, 0.288782],
+ [0.336372, 0.282293],
+ [0.269314, 0.271477],
+ [0.263906, 0.27364],
+ [0.243356, 0.292027],
+ [0.21199, 0.244438],
+ [0.207664, 0.241193],
+ [0.149258, 0.231459],
+ [0.138443, 0.231459],
+ [0.118974, 0.248764],
+ [0.116811, 0.222806],
+ [0.117892, 0.162237],
+ [0.115729, 0.076792],
+ [0.06814, 0.011897],
+ [0.062732, 0.007571],
+ [0.005408, 0.0],
+ [0.031135, 0.06227],
+ [0.252301, 0.266258],
+ [0.541104, 0.544325],
+ [0.359663, 0.559356],
+ [0.433742, 0.753681],
+ [0.304908, 0.821319],
+ [0.199693, 0.595859],
+ [0.035429, 0.740798],
+ [0.035429, 0.482055],
+ [-0.043263, 0.077874],
+ [0.001082, 0.037855],
+ [0.044345, 0.043263],
+ [0.075711, 0.08869],
+ [0.077874, 0.407756],
+ [0.10924, 0.439122],
+ [0.111403, 0.307169],
+ [0.147095, 0.272559],
+ [0.189277, 0.28013],
+ [0.210909, 0.313659],
+ [0.210909, 0.445612],
+ [0.23903, 0.45102],
+ [0.241193, 0.343943],
+ [0.276885, 0.311496],
+ [0.319067, 0.319067],
+ [0.34178, 0.353677],
+ [0.340698, 0.474815],
+ [0.375309, 0.488875],
+ [0.376391, 0.381799],
+ [0.411001, 0.351514],
+ [0.45102, 0.359085],
+ [0.473733, 0.391533],
+ [0.473733, 0.603523],
+ [0.466162, 0.618665],
+ [0.326638, 0.830655],
+ [-0.012979, 0.832818],
+ [-0.099506, 0.68356],
+ [-0.221724, 0.475896],
+ [-0.223888, 0.407756],
+ [-0.163319, 0.380717],
+ [-0.094098, 0.431551],
+ [-0.0822, 0.531057],
+ [-0.045426, 0.564586],
+ [-0.044345, 0.23254],
+];
+
+pub const BOXES: [[f32; 4]; 44] = [
+ [0.030135, 0.06127, -0.043945, 0.0],
+ [0.330601, 0.483055, 0.116951, 7.0],
+ [0.034429, 0.265258, -0.043945, 7.0],
+ [0.542104, 0.741798, 0.116951, 8.0],
+ [0.034429, 0.355442, -0.043945, 15.0],
+ [0.305908, 0.822319, 0.116951, 8.0],
+ [0.267405, 0.532589, -0.043945, 23.0],
+ [0.542104, 0.822319, 0.082595, 8.0],
+ [-0.045345, 0.036855, -0.044263, 31.0],
+ [0.045345, 0.23354, 0.059405, 8.0],
+ [-0.224888, 0.072548, -0.044263, 39.0],
+ [0.059405, 0.565586, 0.059405, 8.0],
+ [-0.032366, 0.042263, -0.044263, 47.0],
+ [0.148095, 0.408756, 0.059405, 8.0],
+ [0.074711, 0.08769, -0.044263, 55.0],
+ [0.184869, 0.464999, 0.064813, 8.0],
+ [0.146095, 0.271559, -0.044263, 63.0],
+ [0.211908, 0.446612, 0.038855, 8.0],
+ [0.23803, 0.310496, -0.044263, 71.0],
+ [0.320067, 0.460672, 0.06265, 8.0],
+ [0.275885, 0.310496, -0.044263, 79.0],
+ [0.412001, 0.475815, 0.047508, 8.0],
+ [0.375391, 0.350514, -0.044263, 87.0],
+ [0.45202, 0.38388, 0.044263, 4.0],
+ [0.410001, 0.350514, -0.044263, 91.0],
+ [0.474733, 0.401185, 0.044263, 5.0],
+ [-0.224888, 0.379717, -0.044263, 96.0],
+ [-0.0812, 0.532057, 0.047508, 8.0],
+ [-0.095098, 0.228295, -0.044263, 104.0],
+ [0.211908, 0.565586, 0.064813, 8.0],
+ [-0.222724, 0.430551, -0.044263, 112.0],
+ [-0.011979, 0.833818, 0.053998, 8.0],
+ [-0.100506, 0.438122, -0.044263, 120.0],
+ [0.327638, 0.833818, 0.0832, 8.0],
+ [0.127708, 0.312659, -0.044263, 128.0],
+ [0.282211, 0.467162, 0.064813, 8.0],
+ [0.245601, 0.352677, -0.044263, 136.0],
+ [0.474733, 0.604523, 0.06265, 8.0],
+ [0.178543, 0.390533, -0.044263, 144.0],
+ [0.474733, 0.639133, 0.0832, 8.0],
+ [0.200174, 0.483549, -0.044263, 152.0],
+ [0.474733, 0.639133, 0.0832, 4.0],
+ [-0.013979, 0.487875, -0.044263, 156.0],
+ [0.467162, 0.833818, 0.0832, 5.0],
+];
diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs
index 097863e29..971525b53 100644
--- a/crates/compositor/src/regions.rs
+++ b/crates/compositor/src/regions.rs
@@ -8,7 +8,9 @@
use crate::camera::Follow;
use crate::cursor::CursorTrack;
-use crate::scene::{SceneCameraFullscreenRegion, SceneSpeedRegion, SceneZoomRegion};
+use crate::scene::{
+ SceneCameraFullscreenRegion, SceneCameraLayoutRegion, SceneSpeedRegion, SceneZoomRegion,
+};
/// Quantification commune vidéo/audio : le web retranche exactement 1 ms avant `ceil`.
pub const SPEED_FRAME_EPSILON_SEC: f64 = 0.001;
@@ -49,18 +51,22 @@ pub fn speed_segments_for_window(
for region in overlapping {
let start = region.start_sec.max(source_start_sec).max(cursor);
let end = region.end_sec.min(source_end_sec);
+ // Projection can leave a zero-width region at a trim boundary. It may still pass
+ // the overlap filter when rounding puts its start just inside the clip window. Skip
+ // it before emitting the 1x gap, or the unchanged cursor makes that gap render twice.
+ if end <= start {
+ continue;
+ }
if start > cursor {
push_speed_segment(&mut spans, cursor, start, 1.0, fps);
}
- if end > start {
- let speed = if region.speed.is_finite() && region.speed > 0.0 {
- region.speed
- } else {
- 1.0
- };
- push_speed_segment(&mut spans, start, end, speed, fps);
- cursor = end;
- }
+ let speed = if region.speed.is_finite() && region.speed > 0.0 {
+ region.speed
+ } else {
+ 1.0
+ };
+ push_speed_segment(&mut spans, start, end, speed, fps);
+ cursor = end;
}
if cursor < source_end_sec {
push_speed_segment(&mut spans, cursor, source_end_sec, 1.0, fps);
@@ -267,8 +273,11 @@ fn push_speed_segment(
}
// mêmes fenêtres de transition que le web (TRANSITION_WINDOW_MS etc., converties en secondes).
-const TRANSITION_WINDOW_S: f32 = 1.01505;
-const FULLSCREEN_LEAD_OUT_WINDOW_S: f32 = TRANSITION_WINDOW_S * 1.5;
+pub(crate) const TRANSITION_WINDOW_S: f32 = 1.01505;
+pub(crate) const FULLSCREEN_LEAD_OUT_WINDOW_S: f32 = TRANSITION_WINDOW_S * 1.5;
+/// Two camera regions (layout or Full Camera) closer than this (source seconds) meet at a
+/// seam: they hand over directly, without the default PiP in between.
+pub(crate) const REGION_SEAM_S: f64 = 0.001;
// Durée d'un zoom à l'écran selon l'échelle visée, cf. `zoom_transition_s` ; miroir de
// `ZOOM_TRANSITION_BASE_MS` / `ZOOM_TRANSITION_PER_LN_MS` (TS).
const ZOOM_TRANSITION_BASE_S: f32 = 0.6;
@@ -324,7 +333,7 @@ fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
}
/// Port de `easeOutScreenStudio` (TS) : cubic-bezier(0.16, 1, 0.3, 1).
-fn ease_out_screen_studio(t: f32) -> f32 {
+pub(crate) fn ease_out_screen_studio(t: f32) -> f32 {
cubic_bezier(0.16, 1.0, 0.3, 1.0, t)
}
@@ -462,6 +471,10 @@ pub fn zoom_cursor_alpha(regions: &[SceneZoomRegion], t: f32, clock: &ScreenCloc
/// pixel shader par `compositor.rs`, ce module ne fait que le calcul temporel).
pub struct ZoomState {
pub scale: f32,
+ /// Force de la région (0..1) : plein régime sur son span et sur les transitions/paliers
+ /// chaînés, 0 hors zoom. C'est l'enveloppe du zoom — le rétrécissement réactif de la webcam
+ /// (`reactive_scale`) s'y accroche pour animer sa taille, au lieu de suivre la profondeur.
+ pub strength: f32,
pub focus: [f32; 2],
pub rotation: [f32; 3],
/// À quel point un préset 3D est installé (0..1) : la force de la région quand elle en porte
@@ -479,6 +492,7 @@ pub struct ZoomState {
const IDENTITY_ZOOM: ZoomState = ZoomState {
scale: 1.0,
+ strength: 0.0,
focus: [0.5, 0.5],
rotation: [0.0, 0.0, 0.0],
tilt: 0.0,
@@ -865,6 +879,7 @@ pub fn zoom_state_in(
|p: [f32; 2], q: [f32; 2]| [lerp(p[0], q[0], progress), lerp(p[1], q[1], progress)];
return ZoomState {
scale: scale_lerp(cur.scale, next.scale, progress),
+ strength: 1.0,
focus: [
lerp(cur_focus[0], next_focus[0], progress),
lerp(cur_focus[1], next_focus[1], progress),
@@ -886,6 +901,7 @@ pub fn zoom_state_in(
let seen = orbit_at(next, t, frame);
return ZoomState {
scale: next.scale,
+ strength: 1.0,
focus: resolve_focus(next, t, cursor, clock),
rotation: fixed_rotation(next),
tilt: fixed_flag(next),
@@ -939,6 +955,7 @@ pub fn zoom_state_in(
let seen = orbit_at(r, t, frame);
ZoomState {
scale,
+ strength,
focus: [ease(focus[0]), ease(focus[1])],
rotation: lerp_rotation3d([0.0, 0.0, 0.0], fixed_rotation(r), strength),
tilt: fixed_flag(r) * strength,
@@ -961,16 +978,20 @@ fn camera_fullscreen_region_phase(
region: &SceneCameraFullscreenRegion,
t: f32,
clock: &ScreenClock,
+ layouts: &[SceneCameraLayoutRegion],
) -> f32 {
let start = region.start_sec as f32;
let end = region.end_sec as f32;
if t <= start || t >= end {
return 0.0;
}
+ // At a seam with a layout region the camera is already frame-filling on that side: the
+ // layout region's own glide (`camera_layers_at`) moves it, so the phase holds 1 there.
+ let seams = full_camera_seams(region, layouts);
let (start, end, t) = (clock.at(start), clock.at(end), clock.at(t));
let half = (end - start) * 0.5;
- let lead_in = TRANSITION_WINDOW_S.min(half);
- let lead_out = FULLSCREEN_LEAD_OUT_WINDOW_S.min(half);
+ let lead_in = if seams.start { 0.0 } else { TRANSITION_WINDOW_S.min(half) };
+ let lead_out = if seams.end { 0.0 } else { FULLSCREEN_LEAD_OUT_WINDOW_S.min(half) };
let lead_in_end = start + lead_in;
let lead_out_start = end - lead_out;
if t < lead_in_end {
@@ -990,16 +1011,38 @@ fn camera_fullscreen_region_phase(
}
}
+/// Which ends of a Full Camera region meet a (non-empty) camera layout region at a seam
+/// (`REGION_SEAM_S`): `start` = a layout region ends where it starts, `end` = one starts where
+/// it ends.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub(crate) struct FullCameraSeams {
+ pub start: bool,
+ pub end: bool,
+}
+
+pub(crate) fn full_camera_seams(
+ region: &SceneCameraFullscreenRegion,
+ layouts: &[SceneCameraLayoutRegion],
+) -> FullCameraSeams {
+ let mut seams = FullCameraSeams::default();
+ for l in layouts.iter().filter(|l| l.end_sec > l.start_sec) {
+ seams.start |= (l.end_sec - region.start_sec).abs() <= REGION_SEAM_S;
+ seams.end |= (l.start_sec - region.end_sec).abs() <= REGION_SEAM_S;
+ }
+ seams
+}
+
/// Phase Full Camera (0..1) au temps `t`. Régions superposées (ne devrait pas arriver, gardé
/// défensif comme le web) → la plus forte gagne.
fn camera_fullscreen_phase_at(
regions: &[SceneCameraFullscreenRegion],
t: f32,
clock: &ScreenClock,
+ layouts: &[SceneCameraLayoutRegion],
) -> f32 {
let mut strongest = 0.0f32;
for r in regions {
- let s = camera_fullscreen_region_phase(r, t, clock);
+ let s = camera_fullscreen_region_phase(r, t, clock, layouts);
if s > strongest {
strongest = s;
}
@@ -1012,8 +1055,9 @@ pub fn camera_fullscreen_progress_at(
regions: &[SceneCameraFullscreenRegion],
t: f32,
clock: &ScreenClock,
+ layouts: &[SceneCameraLayoutRegion],
) -> f32 {
- ease_out_screen_studio(camera_fullscreen_phase_at(regions, t, clock))
+ ease_out_screen_studio(camera_fullscreen_phase_at(regions, t, clock, layouts))
}
/// Ce qui reste de la bulle PiP (coins arrondis, ombre) au temps `t` : 1 = bulle entière,
@@ -1026,8 +1070,72 @@ pub fn camera_fullscreen_shape_at(
regions: &[SceneCameraFullscreenRegion],
t: f32,
clock: &ScreenClock,
+ layouts: &[SceneCameraLayoutRegion],
+) -> f32 {
+ 1.0 - smoothstep(0.5, 1.0, camera_fullscreen_phase_at(regions, t, clock, layouts))
+}
+
+/// The Full Camera region in effect at `t`: the one with the strongest phase, so a region's
+/// orientation and its grow/shrink always refer to the same region. `None` wherever the
+/// envelope is 0 — outside every region and on their bounds.
+pub fn camera_fullscreen_region_at<'a>(
+ regions: &'a [SceneCameraFullscreenRegion],
+ t: f32,
+ clock: &ScreenClock,
+ layouts: &[SceneCameraLayoutRegion],
+) -> Option<&'a SceneCameraFullscreenRegion> {
+ let mut best: Option<(&'a SceneCameraFullscreenRegion, f32)> = None;
+ for r in regions {
+ let phase = camera_fullscreen_region_phase(r, t, clock, layouts);
+ if phase > 0.0 && best.map_or(true, |(_, b)| phase > b) {
+ best = Some((r, phase));
+ }
+ }
+ best.map(|(r, _)| r)
+}
+
+/// How long a turned section's camera takes to sharpen after the hold, and to blur again
+/// before the shrink. See `camera_fullscreen_cover_at`.
+pub const DESK_COVER_FADE_S: f32 = 0.5;
+
+/// Cover strength of the webcam at `t` (0 = sharp, 1 = fully blurred and dimmed). Only a
+/// turned section (a camera tilted onto the desk) is covered: the camera is moving at both
+/// ends of such a section, so the picture is hidden for exactly the grow and the shrink, plus a
+/// short fade into and out of the steady part. Measured on screen time like the grow, and 0
+/// on and outside the bounds like the grow. At a seam with a layout region (`layouts`) that
+/// side has no grow or shrink to hold over: the cover is only its own fade there.
+pub fn camera_fullscreen_cover_at(
+ regions: &[SceneCameraFullscreenRegion],
+ t: f32,
+ clock: &ScreenClock,
+ layouts: &[SceneCameraLayoutRegion],
) -> f32 {
- 1.0 - smoothstep(0.5, 1.0, camera_fullscreen_phase_at(regions, t, clock))
+ let Some(r) = camera_fullscreen_region_at(regions, t, clock, layouts) else {
+ return 0.0;
+ };
+ if r.rotation != 180 {
+ return 0.0;
+ }
+ let seams = full_camera_seams(r, layouts);
+ let (start, end, t) = (clock.at(r.start_sec as f32), clock.at(r.end_sec as f32), clock.at(t));
+ // Each hold gets at most half the section; the two fades share what is left, so the start
+ // fade ends at or before the point where the end fade begins and the cover never jumps.
+ let len = end - start;
+ let half = len * 0.5;
+ let hold_in = if seams.start { 0.0 } else { TRANSITION_WINDOW_S.min(half) };
+ let hold_out = if seams.end { 0.0 } else { FULLSCREEN_LEAD_OUT_WINDOW_S.min(half) };
+ let steady = (len - hold_in - hold_out).max(0.0);
+ let fade = DESK_COVER_FADE_S.min(steady * 0.5);
+ let side = |since: f32, hold: f32, fade: f32| -> f32 {
+ if since <= hold {
+ 1.0
+ } else if fade > 0.0 && since < hold + fade {
+ 1.0 - smoothstep(0.0, fade, since - hold)
+ } else {
+ 0.0
+ }
+ };
+ side(t - start, hold_in, fade).max(side(end - t, hold_out, fade))
}
// ============ Rotation 3D (tilt perspective, présets iso/left/right) ================
@@ -1965,6 +2073,9 @@ mod zoom_focus_tests {
clip_index: None,
start_sec: 18.0 * k,
end_sec: 22.0 * k,
+ rotation: 0,
+ mirror: None,
+ full_frame: false,
}]
};
let (sped, plain) = (zooms(4.0), zooms(1.0));
@@ -1984,21 +2095,223 @@ mod zoom_focus_tests {
b.scale
);
assert!((a.focus[0] - b.focus[0]).abs() < 1e-4, "t={t}");
- let a = camera_fullscreen_progress_at(&full_camera(4.0), t, &clock);
+ let a = camera_fullscreen_progress_at(&full_camera(4.0), t, &clock, &[]);
let b =
- camera_fullscreen_progress_at(&full_camera(1.0), t / 4.0, &ScreenClock::default());
+ camera_fullscreen_progress_at(&full_camera(1.0), t / 4.0, &ScreenClock::default(), &[]);
assert!((a - b).abs() < 1e-4, "Full Camera t={t} : {a} vs {b}");
}
}
+ fn cam(start: f64, end: f64, rotation: u16) -> SceneCameraFullscreenRegion {
+ SceneCameraFullscreenRegion {
+ clip_index: None,
+ start_sec: start,
+ end_sec: end,
+ rotation,
+ mirror: None,
+ full_frame: rotation != 0,
+ }
+ }
+
+ /// Orientation and transition must name the same region: inside it the region, at its
+ /// bounds and outside nothing, exactly where the progress envelope is 0.
+ #[test]
+ fn the_full_camera_region_at_t_is_the_one_the_envelope_uses() {
+ let r = [cam(10.0, 20.0, 180), cam(30.0, 40.0, 0)];
+ let clock = ScreenClock::default();
+ assert_eq!(camera_fullscreen_region_at(&r, 15.0, &clock, &[]).map(|c| c.rotation), Some(180));
+ assert_eq!(camera_fullscreen_region_at(&r, 35.0, &clock, &[]).map(|c| c.rotation), Some(0));
+ for t in [5.0, 10.0, 20.0, 25.0] {
+ assert!(camera_fullscreen_region_at(&r, t, &clock, &[]).is_none(), "t={t}");
+ assert_eq!(camera_fullscreen_progress_at(&r, t, &clock, &[]), 0.0, "t={t}");
+ }
+ }
+
+
+ /// The hold covers exactly the grow; then the picture sharpens over the fade.
+ #[test]
+ fn a_turned_section_is_covered_while_the_camera_moves() {
+ let r = [cam(10.0, 30.0, 180)];
+ let clock = ScreenClock::default();
+ let c = |t: f32| camera_fullscreen_cover_at(&r, t, &clock, &[]);
+ assert_eq!(c(10.0 + 0.01), 1.0, "start of the hold");
+ assert_eq!(c(10.0 + TRANSITION_WINDOW_S - 0.01), 1.0, "end of the hold");
+ let mid_fade = c(10.0 + TRANSITION_WINDOW_S + DESK_COVER_FADE_S / 2.0);
+ assert!((mid_fade - 0.5).abs() < 1e-3, "half way through the fade: {mid_fade}");
+ assert_eq!(c(20.0), 0.0, "steady part is sharp");
+ // Mirrored at the end: fade up, then hold for the whole shrink.
+ let mid_rise = c(30.0 - FULLSCREEN_LEAD_OUT_WINDOW_S - DESK_COVER_FADE_S / 2.0);
+ assert!((mid_rise - 0.5).abs() < 1e-3, "half way up: {mid_rise}");
+ assert_eq!(c(30.0 - FULLSCREEN_LEAD_OUT_WINDOW_S + 0.01), 1.0);
+ assert_eq!(c(30.0 - 0.01), 1.0);
+ }
+
+ fn layout_region(start: f64, end: f64) -> SceneCameraLayoutRegion {
+ SceneCameraLayoutRegion { clip_index: None, start_sec: start, end_sec: end, layers: Vec::new() }
+ }
+
+ /// A layout region meeting a Full Camera region at a seam: the phase holds 1 on that side
+ /// (no lead-out before it, no lead-in after it); the other side is unchanged.
+ #[test]
+ fn the_full_camera_phase_holds_at_a_seam_with_a_layout_region() {
+ let clock = ScreenClock::default();
+ let r = [cam(10.0, 20.0, 0)];
+ let p = |t: f32, layouts: &[SceneCameraLayoutRegion]| {
+ camera_fullscreen_progress_at(&r, t, &clock, layouts)
+ };
+ let after = [layout_region(20.0, 25.0)];
+ let before = [layout_region(5.0, 10.0)];
+ for t in [19.0, 19.5, 19.99] {
+ assert_eq!(p(t, &after), 1.0, "t={t}");
+ assert!(p(t, &[]) < 1.0, "a lone region leads out (t={t})");
+ }
+ assert!(p(10.3, &after) < 1.0, "the far side keeps its lead-in");
+ for t in [10.01, 10.3, 10.9] {
+ assert_eq!(p(t, &before), 1.0, "t={t}");
+ }
+ assert!(p(19.5, &before) < 1.0, "the far side keeps its lead-out");
+ // On the bounds the phase is 0 as always: the layout region draws the seam.
+ assert_eq!(p(20.0, &after), 0.0);
+ assert_eq!(p(10.0, &before), 0.0);
+ // Off the seam (10 ms), or an empty layout region, changes nothing.
+ for layouts in [[layout_region(20.01, 25.0)], [layout_region(20.0, 20.0)]] {
+ assert_eq!(p(19.5, &layouts), p(19.5, &[]));
+ }
+ }
+
+ /// A turned section at a seam: no hold on that side, the cover is its own fade there.
+ #[test]
+ fn at_a_seam_the_cover_is_only_its_fade() {
+ let clock = ScreenClock::default();
+ let r = [cam(10.0, 30.0, 180)];
+ let layouts = [layout_region(5.0, 10.0), layout_region(30.0, 35.0)];
+ let c = |t: f32| camera_fullscreen_cover_at(&r, t, &clock, &layouts);
+ assert!(c(10.001) > 0.99, "covered at the seam: {}", c(10.001));
+ let mid_fade = c(10.0 + DESK_COVER_FADE_S / 2.0);
+ assert!((mid_fade - 0.5).abs() < 1e-3, "half way through the fade: {mid_fade}");
+ assert_eq!(c(10.0 + DESK_COVER_FADE_S + 0.01), 0.0, "sharp after the fade");
+ let mid_rise = c(30.0 - DESK_COVER_FADE_S / 2.0);
+ assert!((mid_rise - 0.5).abs() < 1e-3, "half way up: {mid_rise}");
+ assert_eq!(c(30.0 - DESK_COVER_FADE_S - 0.01), 0.0);
+ assert!(c(29.999) > 0.99);
+ // Without the seams the holds are back.
+ let lone = |t: f32| camera_fullscreen_cover_at(&r, t, &clock, &[]);
+ assert_eq!(lone(10.0 + DESK_COVER_FADE_S + 0.01), 1.0);
+ }
+
+ #[test]
+ fn the_cover_is_zero_on_and_outside_the_bounds() {
+ let r = [cam(10.0, 30.0, 180)];
+ let clock = ScreenClock::default();
+ for t in [0.0, 9.99, 10.0, 30.0, 30.01, 40.0] {
+ assert_eq!(camera_fullscreen_cover_at(&r, t, &clock, &[]), 0.0, "t={t}");
+ }
+ }
+
+ #[test]
+ fn a_plain_section_is_never_covered() {
+ let r = [cam(10.0, 30.0, 0)];
+ let clock = ScreenClock::default();
+ for step in 0..=400 {
+ let t = step as f32 * 0.1;
+ assert_eq!(camera_fullscreen_cover_at(&r, t, &clock, &[]), 0.0, "t={t}");
+ }
+ }
+
+ /// Each hold gets at most half the section and the fades share what is left, so a section
+ /// shorter than both holds is covered throughout and a longer one keeps its full fades.
+ #[test]
+ fn short_sections_shrink_the_fades_first() {
+ let clock = ScreenClock::default();
+ let short = [cam(10.0, 11.5, 180)]; // half = 0.75 < hold-in: no fade at all
+ for step in 1..150 {
+ let t = 10.0 + step as f32 * 0.01;
+ let c = camera_fullscreen_cover_at(&short, t, &clock, &[]);
+ assert!((0.0..=1.0).contains(&c), "t={t}: {c}");
+ assert_eq!(c, 1.0, "a section shorter than both holds is covered throughout (t={t})");
+ }
+ // 3.6 s: steady = 3.6 - 1.015 - 1.523 = 1.06, so both ends keep their full 0.5 s fade.
+ let medium = [cam(10.0, 13.6, 180)];
+ let c = |t: f32| camera_fullscreen_cover_at(&medium, t, &clock, &[]);
+ let mid_fade = c(10.0 + TRANSITION_WINDOW_S + DESK_COVER_FADE_S / 2.0);
+ assert!((mid_fade - 0.5).abs() < 1e-3, "start fade half way: {mid_fade}");
+ let mid_rise = c(13.6 - FULLSCREEN_LEAD_OUT_WINDOW_S - DESK_COVER_FADE_S / 2.0);
+ assert!((mid_rise - 0.5).abs() < 1e-3, "end fade half way: {mid_rise}");
+ }
+
+ /// 2.6 s: hold-in 1.015, hold-out 1.3 (half), steady 0.285 — the two fades share it,
+ /// 0.1425 s each, so the start fade ends exactly where the end fade begins.
+ #[test]
+ fn medium_sections_share_the_steady_part_between_the_fades() {
+ let clock = ScreenClock::default();
+ let r = [cam(10.0, 12.6, 180)];
+ let c = |t: f32| camera_fullscreen_cover_at(&r, t, &clock, &[]);
+ let fade = (2.6 - TRANSITION_WINDOW_S - 1.3) / 2.0;
+ let meet = 10.0 + TRANSITION_WINDOW_S + fade;
+ let mid_fade = c(10.0 + TRANSITION_WINDOW_S + fade / 2.0);
+ assert!((mid_fade - 0.5).abs() < 1e-2, "start fade half way: {mid_fade}");
+ assert!(c(meet) < 1e-2, "both fades are near zero where they meet: {}", c(meet));
+ let mid_rise = c(12.6 - 1.3 - fade / 2.0);
+ assert!((mid_rise - 0.5).abs() < 1e-2, "end fade half way: {mid_rise}");
+ assert_eq!(c(12.6 - 1.3 + 0.01), 1.0, "the end hold is half the section");
+ }
+
+ /// No section length makes the cover jump: sampled every 1 ms, no step exceeds 0.1. (At
+ /// 10 ms the shortest legitimate fade — 42 ms in a 2.2 s section — already steps ~0.3 per
+ /// sample, so the finer grid is what tells a ramp from a jump.)
+ #[test]
+ fn the_cover_is_continuous_for_every_section_length() {
+ let clock = ScreenClock::default();
+ for len in [2.0_f32, 2.2, 2.6, 3.0, 3.5, 4.0, 4.5] {
+ let r = [cam(10.0, 10.0 + len as f64, 180)];
+ let steps = (len * 1000.0).round() as i32;
+ let mut prev = camera_fullscreen_cover_at(&r, 10.0 + 0.0005, &clock, &[]);
+ for step in 1..steps {
+ let t = 10.0 + step as f32 * 0.001;
+ let c = camera_fullscreen_cover_at(&r, t, &clock, &[]);
+ assert!((0.0..=1.0).contains(&c), "len={len} t={t}: {c}");
+ assert!((c - prev).abs() <= 0.1, "len={len} t={t}: {prev} -> {c}");
+ prev = c;
+ }
+ }
+ }
+
+ /// Under a speed change the windows are measured on screen time, like the grow.
+ #[test]
+ fn the_cover_runs_on_screen_time() {
+ let clock = ScreenClock::new(&[speed(None, 0.0, 1000.0, 2.0)], 0);
+ let r = [cam(10.0, 40.0, 180)];
+ // At 2x, the 1.015 s hold spans 2.03 s of source time.
+ assert_eq!(camera_fullscreen_cover_at(&r, 10.0 + 1.9, &clock, &[]), 1.0);
+ assert!(camera_fullscreen_cover_at(&r, 10.0 + 3.6, &clock, &[]) < 1.0);
+ }
+ #[test]
+ fn the_region_fields_parse_and_default() {
+ let plain: SceneCameraFullscreenRegion =
+ serde_json::from_str(r#"{"startSec":1,"endSec":2}"#).unwrap();
+ assert_eq!((plain.rotation, plain.mirror, plain.full_frame), (0, None, false));
+ let desk: SceneCameraFullscreenRegion = serde_json::from_str(
+ r#"{"startSec":1,"endSec":2,"rotation":180,"mirror":false,"fullFrame":true}"#,
+ )
+ .unwrap();
+ assert_eq!((desk.rotation, desk.mirror, desk.full_frame), (180, Some(false), true));
+ }
+
/// Les coins suivent la phase, pas le rect. Avec `1 - progrès`, ils étaient carrés presque
/// tout du long de la montée et ne revenaient qu'à la toute fin du retour.
#[test]
fn full_camera_corners_dissolve_late_and_come_back_early() {
- let r = [SceneCameraFullscreenRegion { clip_index: None, start_sec: 10.0, end_sec: 20.0 }];
+ let r = [SceneCameraFullscreenRegion {
+ clip_index: None,
+ start_sec: 10.0,
+ end_sec: 20.0,
+ rotation: 0,
+ mirror: None,
+ full_frame: false,
+ }];
let clock = ScreenClock::default();
let at = |t: f32| {
- (camera_fullscreen_progress_at(&r, t, &clock), camera_fullscreen_shape_at(&r, t, &clock))
+ (camera_fullscreen_progress_at(&r, t, &clock, &[]), camera_fullscreen_shape_at(&r, t, &clock, &[]))
};
// Premier tiers de la montée : la caméra a fait l'essentiel du chemin, coins intacts.
let (progress, shape) = at(10.0 + TRANSITION_WINDOW_S / 3.0);
@@ -2178,6 +2491,32 @@ mod arrow_tests {
let (_, half) = arrow_local_geometry("right", -5.0, [100.0, 100.0]);
assert_eq!(half, 0.0);
}
+
+ /// L'éditeur encadre chaque flèche par l'enveloppe de ses traits (`ARROW_EXTENTS`,
+ /// `src/lib/ai-edition/annotations/arrowBounds.ts`) : elle doit être celle que dessine ce
+ /// module, sinon le gimbal ne tombe plus sur la flèche.
+ #[test]
+ fn the_editor_frames_each_arrow_by_its_strokes() {
+ let ts = include_str!("../../../src/lib/ai-edition/annotations/arrowBounds.ts");
+ let table = &ts[ts.find("ARROW_EXTENTS").expect("la table")..];
+ let table = &table[..table.find("};").expect("sa fin")];
+ for dir in ["up", "down", "left", "right", "up-right", "up-left", "down-right", "down-left"] {
+ let key = if dir.contains('-') { format!("\"{dir}\":") } else { format!("\t{dir}:") };
+ let row = &table[table.find(&key).unwrap_or_else(|| panic!("{dir} absente"))..];
+ let row = &row[row.find('[').expect("[") + 1..row.find(']').expect("]")];
+ let got: Vec = row.split(',').map(|v| v.trim().parse().expect("nombre")).collect();
+ let segs = arrow_segments_viewbox(dir);
+ let xs = segs.iter().flat_map(|s| [s[0], s[2]]);
+ let ys = segs.iter().flat_map(|s| [s[1], s[3]]);
+ let want = [
+ xs.clone().fold(f32::MAX, f32::min),
+ ys.clone().fold(f32::MAX, f32::min),
+ xs.fold(f32::MIN, f32::max),
+ ys.fold(f32::MIN, f32::max),
+ ];
+ assert_eq!(got, want, "{dir}");
+ }
+ }
}
#[cfg(test)]
@@ -3125,6 +3464,16 @@ mod exporter_frame_totals {
60,
"région débordant la fenêtre gardée"
);
+ let issue_876_fps = 60.0;
+ let first_clip = frames(
+ 0.0,
+ 44.81830642526596,
+ &[region(44.818, 44.818, 1.5)],
+ issue_876_fps,
+ );
+ let second_clip = frames(0.0, 47.833333, &[region(0.0, 1.87, 1.5)], issue_876_fps);
+ assert_eq!(first_clip, 2690, "une région vide ne double pas le premier clip");
+ assert_eq!(first_clip + second_clip, 5523, "total corrigé du repro #876");
assert_eq!(
frames(
0.0,
diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs
index de24278fd..98a5957c4 100644
--- a/crates/compositor/src/scene.rs
+++ b/crates/compositor/src/scene.rs
@@ -19,6 +19,67 @@ pub struct SceneClip {
/// Une source sans piste audio décodable garde sa durée via du silence natif.
#[serde(default)]
pub has_audio: bool,
+ /// Cameras 2-4: index k-1 = camera k. An empty `path` means "no camera in this slot".
+ #[serde(default)]
+ pub additional_cameras: Vec,
+}
+
+/// An additional camera (2-4) of a clip. = `CompositorClipCamera` (TS).
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneClipCamera {
+ pub path: String,
+ /// Camera source time = screen source time - this.
+ pub offset_sec: f64,
+}
+
+/// Settings of one camera (index 0 = camera 1). With a `homography` the camera's rotation,
+/// mirror and crop are not sent (the corner order already defines the picture).
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneCamera {
+ pub index: usize,
+ /// 0 or 180.
+ #[serde(default)]
+ pub rotation: u16,
+ #[serde(default)]
+ pub mirror: Option,
+ #[serde(default)]
+ pub crop: Option,
+ /// Row-major 3x3, target uv -> camera uv.
+ #[serde(default)]
+ pub homography: Option<[f32; 9]>,
+ /// Width/height of the corrected picture.
+ #[serde(default)]
+ pub aspect: Option,
+}
+
+/// One camera placed by a layout region, in draw order.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneCameraLayer {
+ pub camera: usize,
+ pub rect: SceneRect,
+ #[serde(default)]
+ pub radius_frac: f32,
+ /// Same vocabulary as `SceneLayout::webcam_shape` (`webcam_shape_code`).
+ #[serde(default)]
+ pub shape: String,
+ /// Covers the screen: drawn without a shadow.
+ #[serde(default)]
+ pub fills_frame: bool,
+}
+
+/// A camera layout (anything but a plain Full Camera) on a span of a clip's source time.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SceneCameraLayoutRegion {
+ #[serde(default)]
+ pub clip_index: Option,
+ pub start_sec: f64,
+ pub end_sec: f64,
+ #[serde(default)]
+ pub layers: Vec,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -291,7 +352,7 @@ impl SceneFrame {
}
/// Fond derrière l'écran (parsé depuis `settings.wallpaper`).
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SceneBackground {
Color { color: String },
@@ -528,7 +589,8 @@ pub struct SceneSpeedRegion {
/// Une zone "Full Camera" de la timeline (temps en secondes) : la caméra PREND tout le cadre
/// pendant cette fenêtre (plein écran net — ni marge, ni arrondi, ni masque, ni fond derrière).
-/// Pas de champs au-delà des bornes temporelles (miroir de `CameraFullscreenRegion`, TS).
+/// The orientation fields are resolved by the app (`sceneDescription.ts`): a desk shot
+/// arrives as `rotation: 180`, `mirror: false`, `fullFrame: true`.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneCameraFullscreenRegion {
@@ -537,6 +599,15 @@ pub struct SceneCameraFullscreenRegion {
pub clip_index: Option,
pub start_sec: f64,
pub end_sec: f64,
+ /// 0 or 180. Anything else is treated as 0 by `frame_geometry::webcam_orientation`.
+ #[serde(default)]
+ pub rotation: u16,
+ /// Mirror inside this region; `None` = the layout's `webcam_mirror`.
+ #[serde(default)]
+ pub mirror: Option,
+ /// Ignore `layout.webcam_crop` inside this region.
+ #[serde(default)]
+ pub full_frame: bool,
}
/// Rendu du curseur.
@@ -730,6 +801,12 @@ pub struct Scene {
/// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants.
#[serde(default)]
pub camera_fullscreen_regions: Vec,
+ /// Settings of the cameras that have any (see `Scene::camera`).
+ #[serde(default)]
+ pub cameras: Vec,
+ /// Layouts that put several cameras on screen. Absent = none.
+ #[serde(default)]
+ pub camera_layout_regions: Vec,
pub cursor: SceneCursor,
/// Global audio finishing. Default keeps old scene payloads bit-for-bit compatible.
#[serde(default)]
@@ -751,6 +828,11 @@ pub struct Scene {
}
impl Scene {
+ /// The settings of camera `index` (0 = camera 1), if the scene carries any.
+ pub fn camera(&self, index: usize) -> Option<&SceneCamera> {
+ self.cameras.iter().find(|c| c.index == index)
+ }
+
/// Parse le JSON produit par `buildSceneDescription` (TS).
pub fn from_json(json: &str) -> anyhow::Result {
Ok(serde_json::from_str(json)?)
@@ -788,6 +870,9 @@ impl Scene {
scene.camera_fullscreen_regions.retain(|region| {
belongs(region.clip_index, region.start_sec, region.end_sec)
});
+ scene.camera_layout_regions.retain(|region| {
+ belongs(region.clip_index, region.start_sec, region.end_sec)
+ });
scene.annotations.retain(|annotation| {
belongs(annotation.clip_index, annotation.start_sec, annotation.end_sec)
});
@@ -943,6 +1028,83 @@ mod tests {
}
}
+ /// A scene JSON with one clip; the arguments are spliced in as extra clip / top-level fields.
+ fn scene_json_with(clip_extra: &str, top_extra: &str) -> String {
+ format!(
+ r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0{clip_extra}}}],"layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":null}}{top_extra}}}"##
+ )
+ }
+
+ #[test]
+ fn parses_extra_cameras_settings_and_layout_regions() {
+ let json = scene_json_with(
+ r#","additionalCameras":[{"path":"/w-2.mp4","offsetSec":0.12},{"path":"","offsetSec":0}]"#,
+ r#","cameras":[{"index":1,"rotation":180,"mirror":true,"crop":{"x":0,"y":0.1,"width":0.5,"height":0.8}},{"index":2,"homography":[1,0,0,0,1,0,0,0,1],"aspect":1.5}],
+ "cameraLayoutRegions":[{"clipIndex":0,"startSec":1,"endSec":4,"underTrim":true,"layers":[
+ {"camera":1,"rect":{"x":0,"y":0,"width":1,"height":1},"radiusFrac":0,"shape":"rectangle","fillsFrame":true},
+ {"camera":0,"rect":{"x":0.7,"y":0.7,"width":0.22,"height":0.2},"radiusFrac":0.12,"shape":"rounded","fillsFrame":false}]}]"#,
+ );
+ let s = Scene::from_json(&json).expect("parse");
+ assert_eq!(s.clips[0].additional_cameras.len(), 2);
+ assert_eq!(s.clips[0].additional_cameras[0].path, "/w-2.mp4");
+ assert!((s.clips[0].additional_cameras[0].offset_sec - 0.12).abs() < 1e-9);
+ assert_eq!(s.clips[0].additional_cameras[1].path, "");
+ assert!(s.camera(0).is_none());
+ let c1 = s.camera(1).expect("camera 1");
+ assert_eq!(c1.rotation, 180);
+ assert_eq!(c1.mirror, Some(true));
+ assert_eq!(c1.crop.expect("crop").height, 0.8);
+ assert!(c1.homography.is_none() && c1.aspect.is_none());
+ let c2 = s.camera(2).expect("camera 2");
+ assert_eq!(c2.rotation, 0);
+ assert_eq!(c2.homography, Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]));
+ assert_eq!(c2.aspect, Some(1.5));
+ assert_eq!(s.camera_layout_regions.len(), 1);
+ let r = &s.camera_layout_regions[0];
+ assert_eq!((r.clip_index, r.start_sec, r.end_sec), (Some(0), 1.0, 4.0));
+ assert_eq!(r.layers.len(), 2);
+ assert_eq!(r.layers[0].camera, 1);
+ assert!(r.layers[0].fills_frame);
+ assert_eq!(r.layers[1].shape, "rounded");
+ assert!((r.layers[1].radius_frac - 0.12).abs() < 1e-6);
+ assert!((r.layers[1].rect.width - 0.22).abs() < 1e-6);
+ }
+
+ #[test]
+ fn old_scene_json_parses_without_the_new_fields() {
+ let s = Scene::from_json(&scene_json_with("", "")).expect("parse");
+ assert!(s.clips[0].additional_cameras.is_empty());
+ assert!(s.cameras.is_empty());
+ assert!(s.camera_layout_regions.is_empty());
+ assert!(s.camera(0).is_none());
+ }
+
+ #[test]
+ fn for_clip_window_keeps_only_this_clips_layout_regions() {
+ let layers = r#""layers":[{"camera":0,"rect":{"x":0,"y":0,"width":1,"height":1},"radiusFrac":0,"shape":"rectangle","fillsFrame":true}]"#;
+ let json = scene_json_with(
+ "",
+ &format!(
+ r#","cameraLayoutRegions":[
+ {{"clipIndex":0,"startSec":1,"endSec":2,{layers}}},
+ {{"clipIndex":1,"startSec":1,"endSec":2,{layers}}},
+ {{"startSec":8,"endSec":9,{layers}}},
+ {{"startSec":20,"endSec":30,{layers}}}]"#
+ ),
+ );
+ let s = Scene::from_json(&json).expect("parse");
+ let w = s.for_clip_window(0, 0.0, 10.0);
+ // Clip 0's own region and the unaddressed one that overlaps the window; not clip 1's,
+ // not the unaddressed one outside the window.
+ let kept: Vec<(Option, f64)> = w
+ .camera_layout_regions
+ .iter()
+ .map(|r| (r.clip_index, r.start_sec))
+ .collect();
+ assert_eq!(kept, vec![(Some(0), 1.0), (None, 8.0)]);
+ assert_eq!(s.for_clip_window(1, 0.0, 10.0).camera_layout_regions.len(), 2);
+ }
+
#[test]
fn parses_webcam_rect_payload() {
// webcamRect est une fraction 0..1 du cadre de sortie ; sa présence doit désactiver
diff --git a/crates/compositor/src/sculpt.rs b/crates/compositor/src/sculpt.rs
index de9c4e65a..89fa9a277 100644
--- a/crates/compositor/src/sculpt.rs
+++ b/crates/compositor/src/sculpt.rs
@@ -1,17 +1,18 @@
-//! Curseurs sculptés : la flèche et la main des cinq thèmes d'origine, modelées en volumes dans les
-//! shaders (mode 15, `sculpt_proto`) au lieu d'extruder leur PNG — capsules et unions lissées,
-//! extrusions arrondies, voxels, polyèdres taillés. Ce module en tient ce que la géométrie doit
-//! savoir côté CPU : l'identifiant que lit le shader, et la boîte du modèle (`SpriteShape`) qui pose
-//! le hotspot, règle la garde au sol et borne la boîte de dessin.
+//! Curseurs sculptés : la flèche et la main des thèmes d'origine, modelées en volumes dans les
+//! shaders (mode 15, `sculpt_proto`) au lieu d'extruder leur PNG — voxels (Pixel Candy), pièces
+//! cerclées du trait de leur dessin (Studio Ink, Pop Coral, Star Sprout). Prism Glow est à part :
+//! un MAILLAGE tracé sur son dessin à facettes, un cristal lancé de rayons boîte par boîte
+//! dans son serti marine (`prism_mesh`). Ce module en tient ce que la géométrie doit savoir côté
+//! CPU : l'identifiant que lit le shader, et la boîte du modèle (`SpriteShape`) qui pose le
+//! hotspot, règle la garde au sol et borne la boîte de dessin.
//!
//! Les formes sont écrites dans le repère du PROTOTYPE où elles ont été dessinées : hauteur du
//! curseur 1, x à droite, y VERS LE HAUT, z vers la caméra, l'écran en z = 0. Les constantes
//! ci-dessous sont le miroir des `SCULPT_*` des trois shaders.
//!
-//! Les voxels de la flèche de Pixel Candy (`SCULPT_ARROWPIX`, `SCULPT_ARROWBACK`) sont son polygone
-//! échantillonné : une cellule de 1/16 porte un voxel quand son centre est à plus de 0,01 dedans, un
-//! bloc de la couche violette quand il est à moins de 0,9 cellule. Changer le polygone, c'est
-//! régénérer ces deux tables.
+//! Les grilles de voxels de Pixel Candy sont des tables que
+//! `scripts/generate-pixel-candy-voxels.mjs` écrit dans les trois shaders : on change la grille
+//! dans le script, on le relance, et on ajuste ici la boîte si la forme a bougé.
use crate::frame_geometry::SpriteShape;
@@ -24,29 +25,48 @@ const ZREF_ARROW: f32 = 0.2;
const ZREF_HAND: f32 = 0.185;
/// Le dessous de tous les modèles, dans le prototype.
const Z_LOW: f32 = 0.05;
-/// Le plus haut : la table de la flèche taillée, l'étoile de Star Sprout sur la manchette.
-const Z_HIGH_ARROW: f32 = 0.29;
+/// Le côté d'un cube de Pixel Candy : seize lignes font un curseur haut de 1.
+const VOX: f32 = 0.0625;
+/// Le plus haut : les yeux de l'étoile de Star Sprout, sur la flèche comme sur la manchette.
+const Z_HIGH_ARROW: f32 = 0.31;
const Z_HIGH_HAND: f32 = 0.4;
-/// Boîtes des deux formes dans le prototype (x0, x1, haut), qui contiennent leurs cinq variantes :
-/// couche violette des voxels, étoile, cristal compris. Leur hauteur est celle du sprite, 1 / SCULPT_SCALE :
-/// le mode 15 tient le plus grand côté de la boîte pour 1 (`sprite_size`).
-const BOX_ARROW: [f32; 3] = [-0.1, 0.75, 0.09];
-const BOX_HAND: [f32; 3] = [-0.27, 0.73, 0.07];
+/// Boîtes des modèles dans le prototype (x0, x1, haut). Leur hauteur est celle du sprite,
+/// 1 / SCULPT_SCALE : le mode 15 tient le plus grand côté de la boîte pour 1 (`sprite_size`).
+/// Celles des cubes de Pixel Candy ; puis des thèmes cerclés, que leur trait élargit et que
+/// débordent l'étoile et les feuilles (Star Sprout), les tirets du clic et les calques (Pop
+/// Coral), le pouce.
+const BOX_PIXEL_ARROW: [f32; 3] = [-0.03, 0.72, 0.03];
+const BOX_PIXEL_HAND: [f32; 3] = [-0.345, 0.72, 0.03];
+const BOX_INK_ARROW: [f32; 3] = [-0.08, 0.6, 0.03];
+const BOX_INK_HAND: [f32; 3] = [-0.36, 0.6, 0.03];
+const BOX_CORAL_ARROW: [f32; 3] = [-0.35, 0.6, 0.06];
+const BOX_CORAL_HAND: [f32; 3] = [-0.38, 0.62, 0.04];
+const BOX_SPROUT_ARROW: [f32; 3] = [-0.08, 0.87, 0.03];
+const BOX_SPROUT_HAND: [f32; 3] = [-0.33, 0.56, 0.03];
/// Dans l'ordre des identifiants du shader.
const THEMES: [&str; 5] = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"];
-/// Le haut de la silhouette (y du prototype) : la couche violette des voxels dépasse la pointe
-/// d'une cellule, la pointe arrondie de la flèche et le sommet de sa table taillée dépassent le
-/// hotspot de 0,03, le bout de l'index y est.
-fn silhouette_top(theme: usize, arrow: bool) -> f32 {
+/// La boîte du modèle (x0, x1, haut) dans le prototype.
+fn model_box(theme: usize, arrow: bool) -> [f32; 3] {
match (THEMES[theme], arrow) {
- ("pixel-candy", _) => 0.0625,
- (_, true) => 0.03,
- (_, false) => 0.0,
+ ("studio-ink", true) => BOX_INK_ARROW,
+ ("studio-ink", false) => BOX_INK_HAND,
+ ("pop-coral", true) => BOX_CORAL_ARROW,
+ ("pop-coral", false) => BOX_CORAL_HAND,
+ ("pixel-candy", true) => BOX_PIXEL_ARROW,
+ ("pixel-candy", false) => BOX_PIXEL_HAND,
+ ("star-sprout", true) => BOX_SPROUT_ARROW,
+ ("star-sprout", false) => BOX_SPROUT_HAND,
+ (other, _) => unreachable!("thème sculpté inconnu : {other}"),
}
}
+/// Le curseur sculpté que nomme la scène est de verre (Prism Glow) : il réfracte ce qui est dessous.
+pub fn refracts(name: &str) -> bool {
+ sculpted_shape(name).is_some_and(|s| THEMES[((s.sculpt - 1) / 2) as usize] == "prism-glow")
+}
+
/// Le curseur sculpté que nomme la scène (`"/<état>"`, cf. `resolveCursorSprites`), sous
/// la forme que le mode 15 attend. `None` pour un nom inconnu : l'appelant extrude le sprite.
pub fn sculpted_shape(name: &str) -> Option {
@@ -57,7 +77,20 @@ pub fn sculpted_shape(name: &str) -> Option {
"pointer" => false,
_ => return None,
};
- let [x0, x1, y1] = if arrow { BOX_ARROW } else { BOX_HAND };
+ let sculpt = 1 + 2 * theme as u32 + u32::from(!arrow);
+ if THEMES[theme] == "prism-glow" {
+ // Le maillage est déjà dans le repère du modèle : sa boîte, son dessus et son dessous.
+ let m = &crate::prism_mesh::MODELS[usize::from(!arrow)];
+ return Some(SpriteShape {
+ size: m.size,
+ hotspot: m.hotspot,
+ top: m.top,
+ max_height: m.height,
+ thick: m.thick,
+ sculpt,
+ });
+ }
+ let [x0, x1, y1] = model_box(theme, arrow);
let (zref, z_high) = if arrow { (ZREF_ARROW, Z_HIGH_ARROW) } else { (ZREF_HAND, Z_HIGH_HAND) };
let s = SCULPT_SCALE;
// Repère du modèle : y vers le bas, le coin haut-gauche de la boîte est donc (x0, y1).
@@ -65,10 +98,10 @@ pub fn sculpted_shape(name: &str) -> Option {
Some(SpriteShape {
size,
hotspot: [-x0 * s / size[0], y1 * s],
- top: (y1 - silhouette_top(theme, arrow)) * s,
+ top: y1 * s,
max_height: (z_high - zref) * s,
thick: (zref - Z_LOW) * s,
- sculpt: 1 + 2 * theme as u32 + u32::from(!arrow),
+ sculpt,
})
}
@@ -99,20 +132,21 @@ mod tests {
#[test]
fn other_states_and_themes_keep_the_extruded_sprite() {
- for name in ["default/arrow", "pop-coral/text", "pop-coral", "", "pop-coral/arrow/x"] {
+ for name in ["default/arrow", "pop-coral/text", "pop-coral", "", "pop-coral/arrow/x", "prism-glow/text"] {
assert!(sculpted_shape(name).is_none(), "{name}");
}
}
/// Le hotspot est dans la boîte, sous le haut de la silhouette ; le modèle a une épaisseur et
/// une hauteur ; la boîte a la hauteur du sprite, son plus grand côté, comme le veut le mode 15.
+ /// Le serti de Prism Glow est le plus mince (0,043) : c'est son épaisseur dans le dessin.
#[test]
fn the_box_holds_the_hotspot_and_the_silhouette_top() {
for name in NAMES {
let s = sculpted_shape(name).unwrap();
assert!((0.0..1.0).contains(&s.hotspot[0]) && (0.0..1.0).contains(&s.hotspot[1]), "{name}");
assert!(s.top <= s.hotspot[1] + 1e-6, "{name} : haut de silhouette sous le hotspot");
- assert!(s.thick > 0.05 && s.max_height > 0.0, "{name}");
+ assert!(s.thick > 0.04 && s.max_height > 0.0, "{name}");
assert!(s.size[0] < 1.0 && s.size[1] == 1.0, "{name} : le plus grand côté n'est pas 1");
}
}
@@ -131,6 +165,8 @@ mod tests {
("SCULPT_ZREF_ARROW", ZREF_ARROW),
("SCULPT_ZREF_HAND", ZREF_HAND),
("SCULPT_HOVER", Z_LOW),
+ ("SCULPT_VOX", VOX),
+ ("PRISM_BEVEL", crate::prism_mesh::BEVEL),
] {
let line = src
.lines()
@@ -145,4 +181,158 @@ mod tests {
}
}
}
+
+ /// Les nombres de la table `name` d'un shader, du `=` à la fin de la liste, constructeurs de
+ /// vecteurs retirés : la même table s'écrit alors pareil dans les trois langages.
+ fn table(src: &str, name: &str) -> Vec {
+ // La déclaration : `NAME =` en WGSL, `NAME[taille]` en HLSL et en Metal (pas un `NAME[i]` du code).
+ let decl = format!("{name}[");
+ let sized = |&i: &usize| src[i + decl.len()..].starts_with(|c: char| c.is_ascii_digit());
+ let at = src.find(&format!("{name} =")).or_else(|| src.match_indices(&decl).map(|(i, _)| i).find(sized));
+ let rest = &src[at.expect(name)..];
+ let rest = &rest[rest.find('=').unwrap()..];
+ let open = rest.find(['(', '{']).unwrap() + 1;
+ let close = [rest.find(");"), rest.find("};")].into_iter().flatten().min().unwrap();
+ let mut list = rest[open..close].to_string();
+ for ty in ["vec2", "vec3", "vec4", "vec4", "float2", "float3", "float4", "int4"] {
+ list = list.replace(ty, "");
+ }
+ list.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
+ .filter(|t| !t.is_empty())
+ .map(|t| t.parse().unwrap_or_else(|_| panic!("{name} : nombre illisible {t}")))
+ .collect()
+ }
+
+ /// Les grilles de Pixel Candy, que `scripts/generate-pixel-candy-voxels.mjs` écrit dans les
+ /// trois shaders, y sont les mêmes ; chaque pixel coloré est plein, et d'une seule couleur.
+ #[test]
+ fn the_voxel_tables_match_in_the_three_shaders() {
+ let hlsl = include_str!("shaders.hlsl");
+ let metal = include_str!("shaders.metal");
+ let wgsl = include_str!("vk_shaders/layer.wgsl");
+ let names = ["PIX_BODY", "PIX_LINE", "PIX_HI", "PIX_SHADE", "PIX_RECT_N", "PIX_GRID", "PIX_ORIGIN", "PIX_BOX", "PIX_RECT"];
+ for name in names {
+ let reference = table(wgsl, name);
+ assert!(!reference.is_empty(), "{name} vide");
+ assert_eq!(table(hlsl, name), reference, "{name} : HLSL et WGSL diffèrent");
+ assert_eq!(table(metal, name), reference, "{name} : Metal et WGSL diffèrent");
+ }
+ let body = table(wgsl, "PIX_BODY");
+ let classes = ["PIX_LINE", "PIX_HI", "PIX_SHADE"].map(|name| table(wgsl, name));
+ for (i, &face) in body.iter().enumerate() {
+ let mut seen = 0u32;
+ for class in &classes {
+ let m = class[i] as u32;
+ assert_eq!(m & !(face as u32), 0, "couleur hors de la forme, ligne {i}");
+ assert_eq!(m & seen, 0, "deux couleurs sur un pixel, ligne {i}");
+ seen |= m;
+ }
+ }
+ }
+
+ /// Seul Prism Glow est de verre, flèche et main.
+ #[test]
+ fn only_prism_glow_refracts() {
+ for name in NAMES {
+ assert_eq!(refracts(name), name.starts_with("prism-glow/"), "{name}");
+ }
+ assert!(!refracts("prism-glow/text"));
+ }
+
+ /// Le maillage de Prism Glow tient dans sa boîte (le shader n'y lance de rayons que là), son
+ /// dessous est à l'épaisseur annoncée (la garde au sol en dépend) et son sommet à la hauteur.
+ #[test]
+ fn the_prism_boxes_hold_their_mesh() {
+ use crate::prism_mesh::{MODELS, POLYS, TRIS};
+ for (k, name) in ["prism-glow/arrow", "prism-glow/pointer"].iter().enumerate() {
+ let s = sculpted_shape(name).unwrap();
+ let m = &MODELS[k];
+ let [x0, y0] = [-s.hotspot[0] * s.size[0], -s.hotspot[1] * s.size[1]];
+ let inside = |x: f32, y: f32| x >= x0 && x <= x0 + s.size[0] && y >= y0 && y <= y0 + s.size[1];
+ let (mut zlo, mut zhi) = (f32::MAX, f32::MIN);
+ for t in TRIS[4 * m.tri_start..4 * (m.tri_start + m.tri_count)].chunks(4) {
+ for e in [[0.0; 3], [t[1][0], t[1][1], t[1][2]], [t[2][0], t[2][1], t[2][2]]] {
+ let [x, y, z] = [t[0][0] + e[0], t[0][1] + e[1], t[0][2] + e[2]];
+ assert!(inside(x, y), "{name} : sommet ({x}, {y}) hors de la boîte");
+ zlo = zlo.min(z);
+ zhi = zhi.max(z);
+ }
+ }
+ let sil = &POLYS[m.sil_start..m.sil_start + m.sil_count];
+ let out = &POLYS[m.out_start..m.out_start + m.out_count];
+ for p in sil.iter().chain(out) {
+ assert!(inside(p[0], p[1]), "{name} : contour ({}, {}) hors de la boîte", p[0], p[1]);
+ }
+ assert!((zlo + s.thick).abs() < 1e-5, "{name} : dessous {zlo}, épaisseur {}", s.thick);
+ assert!((zhi - s.max_height).abs() < 1e-5, "{name} : sommet {zhi}, hauteur {}", s.max_height);
+ }
+ }
+
+ /// Les trois shaders portent le maillage de `prism_mesh.rs` tel quel : triangles, boîtes,
+ /// polygones, et les débuts et longueurs de chaque modèle dans ces tables. Le WGSL lit
+ /// triangles, polygones et boîtes dans un uniform rempli de `prism_mesh.rs`
+ /// (`compositor_linux.rs`) : il n'en déclare que la taille.
+ #[test]
+ fn the_shaders_carry_the_prism_mesh() {
+ use crate::prism_mesh::{PrismModel, BOXES, MODELS, POLYS, TRIS};
+ let per_model = |f: fn(&PrismModel) -> usize| MODELS.iter().map(|m| f(m) as f32).collect::>();
+ let want: [(&str, Vec); 11] = [
+ ("PRISM_TRIS", TRIS.iter().flatten().copied().collect()),
+ ("PRISM_POLY", POLYS.iter().flatten().copied().collect()),
+ ("PRISM_BOXES", BOXES.iter().flatten().copied().collect()),
+ ("PRISM_BOX_START", per_model(|m| m.box_start)),
+ ("PRISM_BOX_COUNT", per_model(|m| m.box_count)),
+ ("PRISM_TRI_START", per_model(|m| m.tri_start)),
+ ("PRISM_TRI_COUNT", per_model(|m| m.tri_count)),
+ ("PRISM_SIL_START", per_model(|m| m.sil_start)),
+ ("PRISM_SIL_COUNT", per_model(|m| m.sil_count)),
+ ("PRISM_OUT_START", per_model(|m| m.out_start)),
+ ("PRISM_OUT_COUNT", per_model(|m| m.out_count)),
+ ];
+ let wgsl = include_str!("vk_shaders/layer.wgsl");
+ for field in [
+ format!("tris: array, {}>", TRIS.len()),
+ format!("poly: array, {}>", POLYS.len()),
+ format!("boxes: array, {}>", BOXES.len()),
+ ] {
+ assert!(wgsl.contains(&field), "vk_shaders/layer.wgsl : `{field}` attendu dans PrismMesh");
+ }
+ for (file, src, tables) in [
+ ("shaders.hlsl", include_str!("shaders.hlsl"), &want[..]),
+ ("shaders.metal", include_str!("shaders.metal"), &want[..]),
+ ("vk_shaders/layer.wgsl", wgsl, &want[3..]),
+ ] {
+ for (name, values) in tables {
+ let got = table(src, name);
+ assert_eq!(got.len(), values.len(), "{file} : {name}, nombre de valeurs");
+ for (i, (g, w)) in got.iter().zip(values).enumerate() {
+ assert!((g - w).abs() <= 1e-6 + 1e-6 * w.abs(), "{file} : {name}[{i}] = {g} au lieu de {w}");
+ }
+ }
+ }
+ }
+
+ /// Chaque triangle du cristal est dans une boîte et une seule, qui le contient : le shader ne
+ /// teste les triangles d'une boîte que si le rayon la touche, un triangle hors de sa boîte
+ /// disparaîtrait par endroits.
+ #[test]
+ fn each_crystal_triangle_sits_in_its_box() {
+ use crate::prism_mesh::{BOXES, MODELS, TRIS};
+ for (k, m) in MODELS.iter().enumerate() {
+ let mut next = m.tri_start;
+ for b in m.box_start..m.box_start + m.box_count {
+ let (lo, hi) = (BOXES[2 * b], BOXES[2 * b + 1]);
+ let (first, count) = (lo[3] as usize, hi[3] as usize);
+ assert_eq!(first, next, "modèle {k}, boîte {b} : triangles non contigus");
+ next += count;
+ for t in TRIS[4 * first..4 * (first + count)].chunks(4) {
+ for e in [[0.0; 3], [t[1][0], t[1][1], t[1][2]], [t[2][0], t[2][1], t[2][2]]] {
+ let v = [t[0][0] + e[0], t[0][1] + e[1], t[0][2] + e[2]];
+ assert!((0..3).all(|a| v[a] >= lo[a] && v[a] <= hi[a]), "modèle {k}, boîte {b} : sommet {v:?} dehors");
+ }
+ }
+ }
+ assert_eq!(next, m.tri_start + m.tri_count, "modèle {k} : triangles hors des boîtes");
+ }
+ }
}
diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl
index 30fbf1bd5..e5777929e 100644
--- a/crates/compositor/src/shaders.hlsl
+++ b/crates/compositor/src/shaders.hlsl
@@ -1,5 +1,12 @@
// Compositeur — un draw par calque (quad). NV12->RGB maison (E1), coins arrondis SDF (E2).
// Tout écrit depuis les maths (§7), rien repris de l'ancien paradigme.
+//
+// `ps_main` est compilé DEUX fois (`build.rs`) : sans `LAYER_MODELS` pour tous les calques, et
+// avec, en `ps_main_models`, pour les seuls modèles 3D (modes 15 à 17, `LayerCB::needs_models`).
+// Un pixel shader alloue à chaque draw les registres de sa branche la plus lourde, et ce sont
+// ces modèles ray-tracés : le même shader les portait pour le fond, l'ombre et la vidéo, qui
+// couvrent toute la sortie. Mesuré sur la version WGSL (RADV) : 168 VGPR, 6 vagues par SIMD au
+// lieu de 32, et un export Linux deux fois plus lent ; sans eux, 48.
cbuffer Layer : register(b0)
{
@@ -16,6 +23,9 @@ cbuffer Layer : register(b0)
float4 trail_a; // mode 8 : coins TL, TR du plan à la frame précédente (px locaux, comme fx) ; mode 18 incliné : en fractions de sortie
float4 trail_b; // mode 8 : coins BR, BL du plan à la frame précédente (comme src_prev) ; mode 18 incliné : en fractions de sortie
float4 trail_mb; // mode 8 : x = taps, y = force du flou de mouvement (ceux du mode 0) ; mode 18 incliné : le `mb` du mode 8 (profondeur de champ), et `color.xy` sa lampe ; 0 ailleurs
+ float4 cover; // x = desk-view cover 0..1, y = blur radius (quad px), z = dim, w unused
+ float4 persp[3]; // mode 0: rows of the homography quad point (0..1 in dst) -> camera uv (0..1 of the valid frame), xyz; read when layer_fx.y = 1
+ float4 layer_fx; // x = transparency 0..1 (0 = opaque), every mode; y = 1 when persp applies; z, w unused
};
// Mode 15 (curseur modélisé) : le détail des emplacements est dans `frame_geometry.rs`, en tête
// de la section « Curseur modélisé » (`cursor_model_cb`). Mode 17 (appareil modelé) : en tête de
@@ -81,6 +91,7 @@ float3 sample_yuv(float2 uv)
return yuv709_limited(y, cbcr);
}
+// La même, au niveau 0 : lisible dans une boucle ou une branche (le cristal du mode 15).
// SDF segment à bouts ronds — la primitive des flèches d'annotation, dont les tracés SVG sont
// trois segments `stroke-linecap="round"` (cf. ArrowSvgs.tsx).
float sd_segment(float2 p, float2 a, float2 b)
@@ -105,9 +116,13 @@ float sd_segment(float2 p, float2 a, float2 b)
float sd_round_rect(float2 p, float2 halfsz, float r)
{
float hmin = min(halfsz.x, halfsz.y);
- if (r <= 0.0 || hmin <= 0.0)
+ float2 q0 = abs(p) - halfsz;
+ // Hors des coins, la distance est celle du rectangle vif quel que soit l'exposant : un seul
+ // axe déborde de l'étendue E, qui s'y ajoute puis s'en retranche. E ne dépasse pas 1,42 r
+ // (n = 3) : sous 1,5 r du bord sur un axe, on rend la boîte sans calculer `n` ni E, deux
+ // transcendantes de moins sur presque tous les pixels d'un calque arrondi.
+ if (r <= 0.0 || hmin <= 0.0 || min(q0.x, q0.y) <= -min(1.5 * r, hmin))
{
- float2 q0 = abs(p) - halfsz;
return length(max(q0, 0.0)) + min(max(q0.x, q0.y), 0.0);
}
float n = 3.0 - smoothstep(0.5, 1.0, r / hmin);
@@ -463,6 +478,19 @@ float band_cov(float x, float half_w)
return saturate(half_w + 0.5 - abs(x));
}
+// Camera homography (`persp`, read when `layer_fx.y` = 1): the camera point (0..1 of its valid
+// frame) seen at `local` (0..1 in the layer's dst quad), and in z whether there is one -- 0 when
+// the point is behind the projection (q.z <= 0) or outside the camera frame, where the layer is
+// transparent.
+float3 persp_camera(float2 local)
+{
+ float3 p = float3(local, 1.0);
+ float3 q = float3(dot(persp[0].xyz, p), dot(persp[1].xyz, p), dot(persp[2].xyz, p));
+ float2 cam = q.xy / max(q.z, 1e-6);
+ bool inside = q.z > 0.0 && all(cam >= 0.0) && all(cam <= 1.0);
+ return float3(cam, inside ? 1.0 : 0.0);
+}
+
// Fond flouté pour le mode "blur" de la webcam.
// Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et
// rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide.
@@ -490,10 +518,15 @@ static const float3 VOGEL_TAPS[21] = {
float3(-0.633036, -0.758588, 0.087119)
};
-float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px)
+// Vogel-disc blur of the camera texture at a radius in quad pixels. `valid` is the part of the
+// texture the picture fills (fx.xy): decoders allocate aligned textures (a 1080-line camera in a
+// 1088-line texture), so each tap is clamped half a chroma texel inside it, never into padding.
+float3 blur_webcam_radius(float2 uv, float max_r_px, float2 qpx, float2 local_px, float2 valid)
{
- float max_r_px = max(intensity, 0.0) * 22.0 + 1.5;
float2 step = max_r_px / max(qpx, 1.0);
+ float cw, ch;
+ texUV.GetDimensions(cw, ch);
+ float2 hi = max(valid - 0.5 / max(float2(cw, ch), 1.0), 0.0);
// Interleaved Gradient Noise pour rotation aléatoire par pixel
float noise = frac(52.9829189 * frac(0.06711056 * local_px.x + 0.00583715 * local_px.y));
float angle = noise * 6.2831853;
@@ -506,12 +539,17 @@ float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px)
float2 p = VOGEL_TAPS[k].xy;
float w = VOGEL_TAPS[k].z;
float2 rot_p = float2(p.x * c - p.y * s, p.x * s + p.y * c);
- sum += sample_yuv(saturate(uv + rot_p * step)) * w;
+ sum += sample_yuv(clamp(uv + rot_p * step, 0.0, hi)) * w;
total += w;
}
return sum / max(total, 1e-4);
}
+float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px, float2 valid)
+{
+ return blur_webcam_radius(uv, max(intensity, 0.0) * 22.0 + 1.5, qpx, local_px, valid);
+}
+
// ============ Curseur MODÉLISÉ (mode 15) ============
// L'état courant en objet 3D, lancé de rayons par pixel. Deux sortes d'objets :
// - un curseur SCULPTÉ (`trail_a.x` > 0) : la flèche ou la main d'un des cinq thèmes d'origine,
@@ -587,13 +625,12 @@ static const float MODEL_CONTACT_ALPHA = 0.5;
// curseur 1, x à droite, y VERS LE HAUT, z vers la caméra, l'écran en z = 0 et le modèle posé à
// SCULPT_HOVER au-dessus. `sculpt_point` y amène un point du repère du modèle. Identifiant :
// 1 + 2 × thème + forme ; thèmes 0 Studio Ink, 1 Prism Glow, 2 Pop Coral, 3 Pixel Candy,
-// 4 Star Sprout ; formes 0 flèche, 1 main. Id des matières : 1 corps, 2 liseré ou manchette,
-// 3 étoile, 4 feuilles, 5 yeux, 6 face des voxels, 7 couche violette, 8 cristal.
+// 4 Star Sprout ; formes 0 flèche, 1 main. Id des matières : 1 corps, 2 bande, calque ou
+// manchette, 3 tirets, calque corail ou étoile, 4 feuilles, 5 trait des thèmes cerclés (et
+// yeux), 6 cubes de Pixel Candy, 8 serti de Prism Glow, 9 son cristal (plus bas, `prism_*`).
static const float SCULPT_SCALE = 0.85;
static const float SCULPT_HOVER = 0.05;
static const float SCULPT_VOX = 0.0625;
-static const float SCULPT_AR_ROUND = 0.03;
-static const float SCULPT_HAND_ZC = 0.185;
// Hauteur, dans le prototype, du z = 0 du modèle : le dessus de la pointe de la flèche, l'axe du
// bout de l'index.
static const float SCULPT_ZREF_ARROW = 0.2;
@@ -619,13 +656,6 @@ float2 s_opu(float2 a, float2 b)
return a.x < b.x ? a : b;
}
-float s_capsule(float3 p, float3 a, float3 b, float r)
-{
- float3 pa = p - a, ba = b - a;
- float h = saturate(dot(pa, ba) / dot(ba, ba));
- return length(pa - ba * h) - r;
-}
-
float s_round_box(float3 p, float3 b, float r)
{
float3 q = abs(p) - b + r;
@@ -652,25 +682,91 @@ float2 s_rot(float2 v, float a)
return float2(c * v.x - s * v.y, s * v.x + c * v.y);
}
-// La flèche : polygone à sept sommets, pointe à l'origine.
-static const float2 SCULPT_ARROW[7] = {
- float2(0.0, 0.0), float2(0.0, -0.86), float2(0.215, -0.665), float2(0.37, -1.0),
- float2(0.53, -0.93), float2(0.38, -0.60), float2(0.64, -0.60)
+float s_star5(float2 p, float r, float rf)
+{
+ const float2 k1 = float2(0.809016994375, -0.587785252292);
+ const float2 k2 = float2(-0.809016994375, -0.587785252292);
+ p.x = abs(p.x);
+ p -= 2.0 * max(dot(k1, p), 0.0) * k1;
+ p -= 2.0 * max(dot(k2, p), 0.0) * k2;
+ p.x = abs(p.x);
+ p.y -= r;
+ float2 ba = rf * float2(-k1.y, k1.x) - float2(0.0, 1.0);
+ float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
+ return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
+}
+
+// Lentille : deux disques de rayon r aux centres à ±d sur x, une feuille pointue à ses deux bouts
+// (0, ±sqrt(r² - d²)).
+float s_vesica(float2 p, float r, float d)
+{
+ p = abs(p);
+ float b = sqrt(r * r - d * d);
+ return (p.y - b) * d > p.x * b ? length(p - float2(0.0, b)) : length(p + float2(d, 0.0)) - r;
+}
+
+// Les thèmes CERCLÉS (Studio Ink, Pop Coral, Star Sprout) suivent la planche 3D de référence
+// (`design/cursors/3d-concept.png`) : le trait de leur dessin 2D n'est pas jeté, il devient la
+// matière qui porte tout. Chaque pièce (la flèche, le gant, la manchette, les feuilles, l'étoile)
+// est un plateau sous sa silhouette, un jonc sur le trait et un coussin de couleur bombé dedans.
+// RIM_POLY : sept sommets par polygone (un sommet coupe un côté en deux quand il en faut six).
+// D'abord la couleur de la flèche, commune aux trois thèmes (le trait l'élargit de 0,06, sa pointe
+// arrondie touche le hotspot), puis les paumes de Star Sprout, de Studio Ink et de Pop Coral,
+// arrondies de 0,05.
+static const float2 RIM_POLY[28] = {
+ float2(0.0, -0.06), float2(0.0, -0.7712), float2(0.165, -0.6325), float2(0.313, -0.9318),
+ float2(0.4234, -0.8557), float2(0.2672, -0.5686), float2(0.456, -0.5393),
+ float2(-0.173, -0.5237), float2(-0.016, -0.75), float2(0.329, -0.75), float2(0.411, -0.592),
+ float2(0.411, -0.43), float2(0.19, -0.435), float2(-0.03, -0.44),
+ float2(-0.1825, -0.6215), float2(0.0037, -0.9195), float2(0.3462, -0.9195), float2(0.467, -0.6906),
+ float2(0.467, -0.49), float2(0.2, -0.49), float2(-0.03, -0.49),
+ float2(-0.1807, -0.5534), float2(0.0194, -0.872), float2(0.3485, -0.872), float2(0.47, -0.6795),
+ float2(0.47, -0.49), float2(0.2, -0.49), float2(-0.03, -0.49)
};
-float s_arrow2(float2 p)
+// Les trois gants, mesurés sur leur dessin (g : 0 Star Sprout, 1 Studio Ink, 2 Pop Coral), neuf
+// lignes chacun : les segments de l'index, des trois doigts repliés et du pouce, ceux des trois
+// fentes entre les doigts, puis le V entre le pouce et l'index (un point du bord du pouce et la
+// normale qui entre dans le V). RIM_GLOVE_R, trois lignes par gant : les rayons des quatre doigts ;
+// ceux du pouce et des trois fentes ; le bord de l'index que longe le V, le fond du V, le trait.
+static const float4 RIM_GLOVE[27] = {
+ float4(0.0, -0.1077, 0.0, -0.6), float4(0.1493, -0.3279, 0.1493, -0.6),
+ float4(0.2863, -0.3654, 0.2863, -0.6), float4(0.4193, -0.4043, 0.4193, -0.6),
+ float4(-0.2, -0.478, -0.075, -0.625), float4(0.0788, -0.25, 0.0788, -0.3992),
+ float4(0.2188, -0.3, 0.2188, -0.4234), float4(0.3552, -0.33, 0.3552, -0.4534),
+ float4(-0.0845, -0.5386, 0.762, 0.648),
+ float4(0.0, -0.1186, 0.0, -0.65), float4(0.1614, -0.3838, 0.1614, -0.65),
+ float4(0.3177, -0.427, 0.3177, -0.65), float4(0.4645, -0.4848, 0.4645, -0.65),
+ float4(-0.215, -0.535, -0.1208, -0.6864), float4(0.0832, -0.3, 0.0832, -0.48),
+ float4(0.2414, -0.35, 0.2414, -0.482), float4(0.3959, -0.41, 0.3959, -0.533),
+ float4(-0.122, -0.5718, 0.867, 0.498),
+ float4(0.0, -0.122, 0.0, -0.65), float4(0.172, -0.387, 0.172, -0.65),
+ float4(0.3267, -0.427, 0.3267, -0.65), float4(0.4727, -0.4867, 0.4727, -0.65),
+ float4(-0.199, -0.483, -0.102, -0.645), float4(0.0927, -0.3, 0.0927, -0.476),
+ float4(0.2493, -0.35, 0.2493, -0.478), float4(0.402, -0.41, 0.402, -0.52),
+ float4(-0.0993, -0.5267, 0.858, 0.514)
+};
+static const float4 RIM_GLOVE_R[9] = {
+ float4(0.0552, 0.047, 0.045, 0.0375), float4(0.052, 0.0235, 0.0225, 0.0225), float4(0.0552, -0.548, 0.0525, 0.0),
+ float4(0.064, 0.0585, 0.0585, 0.0515), float4(0.06, 0.0197, 0.0178, 0.0172), float4(0.0648, -0.6, 0.058, 0.0),
+ float4(0.066, 0.0553, 0.0553, 0.0507), float4(0.063, 0.024, 0.022, 0.02), float4(0.0647, -0.555, 0.056, 0.0)
+};
+
+// Distance signée au polygone de RIM_POLY qui commence en `base` : distance aux arêtes, signe par
+// la parité des traversées d'une demi-droite.
+float s_rim_poly(float2 p, int base)
{
- float d = dot(p - SCULPT_ARROW[0], p - SCULPT_ARROW[0]);
+ float d = dot(p - RIM_POLY[base], p - RIM_POLY[base]);
float s = 1.0;
- int j = 6;
- [loop] for (int i = 0; i < 7; i++)
+ int j = base + 6;
+ [loop] for (int i = base; i < base + 7; i++)
{
- float2 e = SCULPT_ARROW[j] - SCULPT_ARROW[i];
- float2 w = p - SCULPT_ARROW[i];
+ float2 e = RIM_POLY[j] - RIM_POLY[i];
+ float2 w = p - RIM_POLY[i];
float2 b = w - e * saturate(dot(w, e) / dot(e, e));
d = min(d, dot(b, b));
- bool c0 = p.y >= SCULPT_ARROW[i].y;
- bool c1 = p.y < SCULPT_ARROW[j].y;
+ bool c0 = p.y >= RIM_POLY[i].y;
+ bool c1 = p.y < RIM_POLY[j].y;
bool c2 = e.x * w.y > e.y * w.x;
if ((c0 && c1 && c2) || (!c0 && !c1 && !c2))
{
@@ -681,311 +777,1262 @@ float s_arrow2(float2 p)
return s * sqrt(d);
}
-float s_star5(float2 p, float r, float rf)
+// Le gant `g` dans le plan, autour de sa paume `palm` : (silhouette, couleur). La silhouette unit
+// la paume, l'index levé, trois doigts repliés et le pouce ; la couleur en est creusée des
+// rainures du dessin, trois fentes entre les doigts et le V entre le pouce et l'index.
+float2 s_rim_glove(float2 p, int g, float palm)
{
- const float2 k1 = float2(0.809016994375, -0.587785252292);
- const float2 k2 = float2(-0.809016994375, -0.587785252292);
- p.x = abs(p.x);
- p -= 2.0 * max(dot(k1, p), 0.0) * k1;
- p -= 2.0 * max(dot(k2, p), 0.0) * k2;
- p.x = abs(p.x);
- p.y -= r;
- float2 ba = rf * float2(-k1.y, k1.x) - float2(0.0, 1.0);
- float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
- return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
+ float4 r0 = RIM_GLOVE_R[3 * g];
+ float4 r1 = RIM_GLOVE_R[3 * g + 1];
+ float4 r2 = RIM_GLOVE_R[3 * g + 2];
+ int i = 9 * g;
+ float f = min(sd_segment(p, RIM_GLOVE[i].xy, RIM_GLOVE[i].zw) - r0.x,
+ sd_segment(p, RIM_GLOVE[i + 1].xy, RIM_GLOVE[i + 1].zw) - r0.y);
+ f = min(f, sd_segment(p, RIM_GLOVE[i + 2].xy, RIM_GLOVE[i + 2].zw) - r0.z);
+ f = min(f, sd_segment(p, RIM_GLOVE[i + 3].xy, RIM_GLOVE[i + 3].zw) - r0.w);
+ f = min(f, sd_segment(p, RIM_GLOVE[i + 4].xy, RIM_GLOVE[i + 4].zw) - r1.x);
+ float grooves = min(sd_segment(p, RIM_GLOVE[i + 5].xy, RIM_GLOVE[i + 5].zw) - r1.y,
+ sd_segment(p, RIM_GLOVE[i + 6].xy, RIM_GLOVE[i + 6].zw) - r1.z);
+ grooves = min(grooves, sd_segment(p, RIM_GLOVE[i + 7].xy, RIM_GLOVE[i + 7].zw) - r1.w);
+ float sil = s_smin(palm, f, 0.03);
+ float4 v = RIM_GLOVE[i + 8];
+ float wedge = max(max(-dot(p - v.xy, v.zw), p.x + r2.x), r2.y - p.y);
+ return float2(sil, max(sil, -min(grooves, wedge)));
}
-// La flèche en volume : extrusion arrondie, bombée d'un dôme qui plafonne avant l'axe médian de
-// la silhouette (un dôme qui monterait encore y plierait le dessus).
-float s_arrow_solid(float3 p, float h, float re, float dome)
+// Une pièce cerclée : (distance, matière). `d` : le bord intérieur du trait dans le plan, `sil` :
+// le même avant les rainures, `dc` : le coussin. Le plateau (matière 5) monte de SCULPT_HOVER à
+// `zt` sous la silhouette élargie du trait `w`. Le jonc (matière 5), un tore de rayon w/2 à la
+// hauteur `zt`, suit le milieu du trait : dans une rainure plus étroite que le trait il n'en reste
+// que la crête, un muret entre les doigts. Le coussin de la matière `mat` sort du plateau, haut de
+// `h` au bord, bombé sur 0,045 puis de `bump` ; sa hauteur variable penche le champ, d'où la
+// distance minorée (× 0,8).
+float2 s_piece(float d, float dc, float sil, float z, float w, float zt, float h, float bump, float mat)
{
- float d2 = s_arrow2(p.xy) - SCULPT_AR_ROUND;
- float hh = h + dome * smoothstep(0.0, 0.07, -d2);
- return s_extrude(d2, p.z - (SCULPT_HOVER + h + dome), hh, re);
+ float tray = s_extrude(sil - w, z - 0.5 * (SCULPT_HOVER + zt), 0.5 * (zt - SCULPT_HOVER), 0.012);
+ float bead = length(float2(d - 0.5 * w, z - zt)) - 0.5 * w;
+ float u = saturate(-dc / 0.045);
+ float cushion = 0.8 * s_extrude(dc, z - zt, h + 0.022 * u * (2.0 - u) + bump, 0.75 * h);
+ return s_opu(float2(min(tray, bead), 5.0), float2(cushion, mat));
}
-// Le liseré de Studio Ink : un jonc posé sur le dessus, en retrait du bord.
-float s_piping(float3 p, float ztop)
+// Pop Coral est du papier découpé : des feuilles à plat, au dessus plat et au bord à peine
+// cassé. La feuille marine sous la silhouette élargie du trait `w`, de SCULPT_HOVER à 0,15 ; la
+// feuille de couleur (matière 1) posée dessus, 0,02 d'épaisseur, découpée des rainures entre les
+// doigts qui laissent voir la marine.
+float2 s_paper(float d, float sil, float z, float w)
{
- float d2 = s_arrow2(p.xy) - SCULPT_AR_ROUND;
- return length(float2(d2 + 0.075, p.z - ztop)) - 0.017;
+ float navy = s_extrude(sil - w, z - 0.5 * (SCULPT_HOVER + 0.15), 0.5 * (0.15 - SCULPT_HOVER), 0.006);
+ float sheet = s_extrude(d, z - 0.16, 0.01, 0.004);
+ return s_opu(float2(navy, 5.0), float2(sheet, 1.0));
}
-// Le gant : l'index levé, trois doigts repliés, le pouce, fondus dans la paume.
-float s_glove(float3 p, float zc)
+// Bosse de rayon r en c, 1 au centre et 0 au bord : un coussin bombé sans pli. Une hauteur tirée
+// de la distance au bord plierait le coussin sur l'axe médian de sa silhouette.
+float s_bump(float2 p, float2 c, float r)
{
- float index = s_capsule(p, float3(0.0, -0.10, zc), float3(0.0, -0.52, zc), 0.098);
- float palm = s_round_box(p - float3(0.185, -0.70, zc), float3(0.255, 0.19, 0.105), 0.1);
- float f1 = s_capsule(p, float3(0.17, -0.57, zc + 0.012), float3(0.17, -0.41, zc + 0.045), 0.086);
- float f2 = s_capsule(p, float3(0.31, -0.59, zc + 0.01), float3(0.31, -0.45, zc + 0.04), 0.08);
- float f3 = s_capsule(p, float3(0.435, -0.625, zc + 0.005), float3(0.435, -0.52, zc + 0.03), 0.07);
- float thumb = s_capsule(p, float3(0.03, -0.77, zc + 0.03), float3(-0.165, -0.60, zc + 0.065), 0.082);
- float d = s_smin(palm, min(f1, min(f2, f3)), 0.035);
- d = s_smin(d, index, 0.05);
- return s_smin(d, thumb, 0.05);
+ float k = saturate(1.0 - dot(p - c, p - c) / (r * r));
+ return k * k;
}
-// La manchette, ronde autour du poignet (elliptique en xz) : le gant y entre.
-float s_cuff(float3 p, float zc)
+// Un tiret du clic de Pop Coral : capsule inégale de a (rayon ra) à b (rayon rb), exacte.
+float s_dash(float2 p, float2 a, float2 b, float ra, float rb)
{
- float3 q = p - float3(0.185, -0.925, zc);
- float2 ab = float2(0.272, 0.12);
- float e = (length(q.xz / ab) - 1.0) * min(ab.x, ab.y);
- return s_extrude(e, q.y, 0.07, 0.06);
+ p -= a;
+ b -= a;
+ float hb = dot(b, b);
+ float2 q = float2(abs(dot(p, float2(b.y, -b.x))), dot(p, b)) / hb;
+ float2 c = float2(sqrt(hb - (ra - rb) * (ra - rb)), ra - rb);
+ float k = c.x * q.y - c.y * q.x;
+ if (k < 0.0)
+ {
+ return sqrt(hb * dot(q, q)) - ra;
+ }
+ if (k > c.x)
+ {
+ return sqrt(hb * (dot(q, q) + 1.0 - 2.0 * q.y)) - rb;
+ }
+ return dot(c, q) - ra;
}
-// L'étoile de Star Sprout, face à la caméra, centrée en c : son visage et ses deux feuilles.
-float2 s_sprout(float3 p, float3 c)
+// Les thèmes cerclés. La flèche : Star Sprout menthe, Pop Coral corail, et Studio Ink noir, dont le
+// trait se partage en un jonc noir au bord et une bande ivoire en relief, le champ restant le
+// dessus du plateau. Le gant ivoire, jaune ou ivoire. Pop Coral est en papier découpé
+// (`s_paper`), ses calques et ses tirets aussi : des feuilles au dessus plat. Puis ce que chacun ajoute devant : le calque
+// jaune de la flèche, le calque corail du gant et les tirets du clic de Pop Coral ; la manchette,
+// l'étoile, ses feuilles et ses yeux de Star Sprout, lus dans le repère de l'étoile (centre `c`,
+// tournée, en unités de son rayon `rs`). Matières : 1 corps, 2 bande ivoire, calque jaune ou
+// manchette, 3 tirets, calque corail ou étoile, 4 feuilles, 5 le trait (plateaux, joncs, yeux).
+float2 s_rimmed(float3 p, int theme, int shape)
{
- float3 q = p - c;
- float st2 = s_star5(q.xy, 0.125, 0.52) - 0.022;
- float2 r = float2(s_extrude(st2, q.z, 0.038, 0.034), 3.0);
- float3 e = float3(abs(q.x) - 0.032, q.y + 0.005, q.z - 0.036);
- r = s_opu(r, float2(length(e) - 0.0135, 5.0));
- float3 l1 = q - float3(-0.04, 0.15, 0.0);
- l1.xy = s_rot(l1.xy, -0.6);
- float3 l2 = q - float3(0.045, 0.155, 0.0);
- l2.xy = s_rot(l2.xy, 0.7);
- float leaves = min(s_ellipsoid(l1, float3(0.03, 0.058, 0.02)), s_ellipsoid(l2, float3(0.03, 0.058, 0.02)));
- return s_opu(r, float2(leaves, 4.0));
+ bool arrow = shape == 0;
+ int g = theme == 4 ? 0 : (theme == 0 ? 1 : 2);
+ float poly = s_rim_poly(p.xy, arrow ? 0 : 7 * g + 7);
+ float2 body = float2(poly, poly);
+ float w = 0.06, zt = 0.17, h = 0.03;
+ float bump = 0.022 * s_bump(p.xy, float2(0.15, -0.45), 0.3);
+ float back = 1e9;
+ if (!arrow)
+ {
+ w = RIM_GLOVE_R[3 * g + 2].z;
+ // Le gant, puis pour Pop Coral son calque corail : le même gant décalé en bas à gauche. Une
+ // boucle et non deux appels (FXC recopierait le gant) ; le calque est sauté quand `p` est
+ // au-dessus du plateau du gant, qui en est alors plus près que lui.
+ [loop] for (int i = 0; i < 2; i++)
+ {
+ float2 q = p.xy + (i == 1 ? float2(0.03, 0.04) : float2(0.0, 0.0));
+ float2 v = s_rim_glove(q, g, (i == 1 ? s_rim_poly(q, 7 * g + 7) : poly) - 0.05);
+ if (i == 1)
+ {
+ back = v.x - w;
+ }
+ else
+ {
+ body = v;
+ if (theme != 2 || v.x - w <= 0.0)
+ {
+ break;
+ }
+ }
+ }
+ zt = 0.185;
+ h = 0.028;
+ bump = 0.03 * s_bump(p.xy, float2(0.19, -0.62), 0.28);
+ }
+ float dc = body.y;
+ if (arrow && theme == 0)
+ {
+ // Studio Ink : pas de coussin, le jonc sur le bord extérieur du trait.
+ body -= 0.015;
+ w = 0.045;
+ dc = 1.0;
+ }
+ float2 r = theme == 2 ? s_paper(body.y, body.x, p.z, w)
+ : s_piece(body.y, dc, body.x, p.z, w, zt, h, bump, 1.0);
+ if (theme == 0)
+ {
+ if (arrow)
+ {
+ float band = s_extrude(abs(poly + 0.0075) - 0.0225, p.z - zt, 0.028, 0.012);
+ r = s_opu(r, float2(band, 2.0));
+ }
+ return r;
+ }
+ if (theme == 2)
+ {
+ float dash;
+ if (arrow)
+ {
+ // Le calque jaune, décalé en bas à gauche, derrière le plateau.
+ float back = s_rim_poly(p.xy + float2(0.025, 0.05), 0) - 0.06;
+ r = s_opu(r, float2(s_extrude(back, p.z - 0.08, 0.03, 0.006), 2.0));
+ dash = min(s_dash(p.xy, float2(-0.1179, -0.1374), float2(-0.1799, -0.0443), 0.028, 0.045),
+ s_dash(p.xy, float2(-0.1347, -0.2493), float2(-0.248, -0.2056), 0.026, 0.042));
+ }
+ else
+ {
+ r = s_opu(r, float2(s_extrude(back, p.z - 0.08, 0.03, 0.006), 3.0));
+ dash = min(s_dash(p.xy, float2(0.216, -0.1695), float2(0.2593, -0.0685), 0.028, 0.042),
+ s_dash(p.xy, float2(0.3054, -0.2339), float2(0.3949, -0.1652), 0.0275, 0.041));
+ }
+ return s_opu(r, float2(s_extrude(dash, p.z - 0.11, 0.06, 0.006), 3.0));
+ }
+ if (!arrow)
+ {
+ // La manchette : un rectangle arrondi, cintré en sourire comme au dessin.
+ float2 cq = p.xy - float2(0.1541, -0.9047);
+ float2 bq = abs(float2(cq.x, cq.y - 0.2066 * cq.x * cq.x)) - float2(0.214, 0.0361);
+ float cuff = length(max(bq, 0.0)) + min(max(bq.x, bq.y), 0.0) - 0.03;
+ r = s_opu(r, s_piece(cuff, cuff, cuff, p.z, 0.0477, 0.225, 0.025, 0.0, 2.0));
+ }
+ float2 c = arrow ? float2(0.6118, -0.8079) : float2(0.15, -0.885);
+ float rs = arrow ? 0.1678 : 0.128;
+ float zs = arrow ? 0.23 : 0.285;
+ float2 q = s_rot(p.xy - c, arrow ? 0.2443 : 0.0) / rs;
+ float star = (s_star5(q, 0.82, 0.55) - 0.18) * rs;
+ float leaves = min(s_vesica(s_rot(q - float2(-0.33, 1.38), -0.925), 0.4296, 0.2626),
+ s_vesica(s_rot(q - float2(0.53, 1.37), 0.873), 0.4296, 0.2626)) * rs;
+ r = s_opu(r, s_piece(leaves, leaves, leaves, p.z, 0.038, zs - 0.02, 0.018, 0.0, 4.0));
+ r = s_opu(r, s_piece(star, star, star, p.z, 0.038, zs, 0.022, 0.018 * s_bump(q, float2(0.0, 0.0), 1.0), 3.0));
+ // Les yeux : deux ovales marine qui affleurent du coussin.
+ float3 e = float3(abs(q.x) - 0.25, q.y - 0.08, (p.z - zs - 0.056) / rs);
+ return s_opu(r, float2(s_ellipsoid(e, float3(0.075, 0.13, 0.1)) * rs, 5.0));
}
-// Pixel Candy : une ligne par entier, bit c = colonne c, ligne 0 juste sous la pointe. La flèche
-// est le polygone échantillonné à 16 cellules par unité (`sculpt.rs` dit comment la régénérer) ;
-// la main est dessinée à la main, un gant échantillonné fondant ses doigts. Les tables *BACK
-// portent la couche violette, la face dilatée d'une cellule, décalée d'une ligne et d'une colonne.
-static const int SCULPT_ARROWPIX[16] = { 0, 1, 3, 7, 31, 63, 127, 255, 511, 63, 55, 115, 113, 224, 224, 64 };
-static const int SCULPT_ARROWBACK[18] = {
- 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 255, 511, 511, 999, 995, 960, 192
+// Pixel Candy est un pixel art, dessiné une fois : la même grille donne ses PNG 2D et, ici, un cube
+// par pixel. Une ligne par entier, bit c = colonne c, ligne 0 en haut. `PIX_BODY` : les pixels
+// pleins ; `PIX_LINE`, `PIX_HI`, `PIX_SHADE` : ceux du contour prune, du reflet rose pâle et de
+// l'ombre rose foncé, le reste est rose. `PIX_GRID` : colonnes, lignes et début de chaque forme
+// dans les tables ; `PIX_ORIGIN` : le coin haut-gauche du pixel (0, 0) ; `PIX_BOX` (centre,
+// demi-côtés) : la boîte de la forme ; `PIX_RECT`, bornés par `PIX_RECT_N` : la forme en
+// rectangles, pour l'ombre. Tables générées par scripts/generate-pixel-candy-voxels.mjs, qui
+// réécrit les trois shaders et les PNG : c'est là qu'on redessine une grille.
+//
+static const int PIX_BODY[32] = { 1, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 127, 247, 243, 480, 192, 48, 120, 120, 120, 504, 4088, 32760, 65534, 65535, 65535, 65534, 32766, 32764, 16380, 16376, 16376 };
+static const int PIX_LINE[32] = { 1, 3, 5, 9, 17, 33, 65, 129, 257, 513, 1985, 73, 149, 147, 288, 192, 48, 72, 72, 72, 456, 3656, 29256, 37454, 32777, 32769, 32770, 16386, 16388, 8196, 8200, 16376 };
+static const int PIX_HI[32] = { 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 18, 34, 32, 64, 0, 0, 16, 16, 16, 16, 16, 16, 16, 22, 18, 4, 4, 8, 8, 16, 0 };
+static const int PIX_SHADE[32] = { 0, 0, 0, 4, 8, 16, 32, 64, 128, 448, 40, 36, 64, 64, 128, 0, 0, 32, 32, 32, 32, 288, 2336, 18720, 16384, 16384, 16384, 8192, 8192, 4096, 8160, 0 };
+static const int PIX_RECT_N[3] = { 0, 17, 29 };
+static const int4 PIX_GRID[2] = {
+ int4(11, 16, 0, 0),
+ int4(16, 16, 16, 0)
};
-static const int SCULPT_HANDPIX[15] = { 4, 14, 14, 14, 110, 878, 7022, 7022, 8190, 8191, 8191, 8191, 8190, 4092, 4092 };
-static const int SCULPT_HANDBACK[17] = {
- 28, 62, 62, 62, 510, 4094, 32766, 32766, 32766, 32767, 32767, 32767, 32767, 32767, 32766, 16380, 16380
+static const float2 PIX_ORIGIN[2] = {
+ float2(0.0, 0.0),
+ float2(-0.3125, 0.0)
};
-// La couche violette de la main, une plage de colonnes par ligne (toutes pleines) : la distance
-// exacte à son contour, pour l'ombre portée.
-static const float2 SCULPT_HANDSPAN[17] = {
- float2(2.0, 4.0), float2(1.0, 5.0), float2(1.0, 5.0), float2(1.0, 5.0), float2(1.0, 8.0),
- float2(1.0, 11.0), float2(1.0, 14.0), float2(1.0, 14.0), float2(1.0, 14.0), float2(0.0, 14.0),
- float2(0.0, 14.0), float2(0.0, 14.0), float2(0.0, 14.0), float2(0.0, 14.0), float2(1.0, 14.0),
- float2(2.0, 13.0), float2(2.0, 13.0)
+static const float4 PIX_BOX[2] = {
+ float4(0.3438, -0.5, 0.3438, 0.5),
+ float4(0.1875, -0.5, 0.5, 0.5)
};
+static const float4 PIX_RECT[29] = {
+ float4(0.0313, -0.0313, 0.0313, 0.0313),
+ float4(0.0625, -0.0938, 0.0625, 0.0313),
+ float4(0.0938, -0.1563, 0.0938, 0.0313),
+ float4(0.125, -0.2188, 0.125, 0.0313),
+ float4(0.1563, -0.2813, 0.1563, 0.0313),
+ float4(0.1875, -0.3438, 0.1875, 0.0313),
+ float4(0.2188, -0.4063, 0.2188, 0.0313),
+ float4(0.25, -0.4688, 0.25, 0.0313),
+ float4(0.2813, -0.5313, 0.2813, 0.0313),
+ float4(0.3125, -0.5938, 0.3125, 0.0313),
+ float4(0.3438, -0.6563, 0.3438, 0.0313),
+ float4(0.2188, -0.7188, 0.2188, 0.0313),
+ float4(0.0938, -0.7813, 0.0938, 0.0313),
+ float4(0.375, -0.8125, 0.125, 0.0625),
+ float4(0.0625, -0.8438, 0.0625, 0.0313),
+ float4(0.4375, -0.9063, 0.125, 0.0313),
+ float4(0.4375, -0.9688, 0.0625, 0.0313),
+ float4(0.0, -0.0313, 0.0625, 0.0313),
+ float4(0.0, -0.1563, 0.125, 0.0938),
+ float4(0.0625, -0.2813, 0.1875, 0.0313),
+ float4(0.1563, -0.3438, 0.2813, 0.0313),
+ float4(0.25, -0.4063, 0.375, 0.0313),
+ float4(0.2188, -0.4688, 0.4688, 0.0313),
+ float4(0.1875, -0.5625, 0.5, 0.0625),
+ float4(0.2188, -0.6563, 0.4688, 0.0313),
+ float4(0.1875, -0.7188, 0.4375, 0.0313),
+ float4(0.2188, -0.7813, 0.4063, 0.0313),
+ float4(0.1875, -0.8438, 0.375, 0.0313),
+ float4(0.2188, -0.9375, 0.3438, 0.0625)
+};
+//
-// Origine de la grille : la flèche part de sa pointe, la main de 2,5 cellules à sa gauche.
-float2 s_grid_origin(int shape)
-{
- return shape == 0 ? float2(0.0, 0.0) : float2(-2.5 * SCULPT_VOX, 0.0);
-}
-
-// Le bit `c` de la ligne `r` d'une table de `rows` lignes et `cols` colonnes. FXC évalue les deux
-// côtés d'un `&&` : l'index est ramené dans la table avant la lecture, le test fait le reste.
-bool s_bit(int row, int r, int c, int rows, int cols)
+// Le bit `c` d'une ligne. FXC évalue les deux côtés d'un `&&` : chaque index est ramené dans sa
+// table avant la lecture, les tests de bornes font le reste.
+bool s_pix_bit(int m, int c)
{
- return r >= 0 && r < rows && c >= 0 && c < cols && ((row >> clamp(c, 0, 31)) & 1) == 1;
+ return ((m >> clamp(c, 0, 31)) & 1) == 1;
}
-// La cellule `id` (colonne, ligne, y vers le haut) porte-t-elle un voxel de la face ?
-bool s_occ(float2 id, int shape)
+// Le pixel (c, r) de la forme `shape`, r compté vers le bas depuis la ligne 0, est-il plein ?
+bool s_pix_body(int shape, int c, int r)
{
- int c = (int)id.x;
- int r = -(int)id.y - 1;
- if (shape == 0)
- {
- return s_bit(SCULPT_ARROWPIX[clamp(r, 0, 15)], r, c, 16, 16);
- }
- return s_bit(SCULPT_HANDPIX[clamp(r, 0, 14)], r, c, 15, 13);
+ int4 g = PIX_GRID[shape];
+ return c >= 0 && c < g.x && r >= 0 && r < g.y && s_pix_bit(PIX_BODY[g.z + clamp(r, 0, g.y - 1)], c);
}
-// … et un bloc de la couche violette.
-bool s_occ_back(float2 id, int shape)
+// La cellule qui contient `p` (prototype, y vers le haut).
+int2 s_pix_cell(float2 p, int shape)
{
- int c = (int)id.x + 1;
- int r = -(int)id.y;
- if (shape == 0)
- {
- return s_bit(SCULPT_ARROWBACK[clamp(r, 0, 17)], r, c, 18, 17);
- }
- return s_bit(SCULPT_HANDBACK[clamp(r, 0, 16)], r, c, 17, 15);
+ float2 o = PIX_ORIGIN[shape];
+ return int2((int)floor((p.x - o.x) / SCULPT_VOX), (int)floor((o.y - p.y) / SCULPT_VOX));
}
-// Chaque cellule pleine est un cube biseauté ; la couche violette est un étage plus bas. Hors du
-// voisinage 3×3, une borne : une cellule au moins, et jamais plus près que la boîte de la forme.
+// Un cube par pixel plein, tous de même hauteur, l'arête à peine cassée pour que chaque cube se
+// lise. Hors du voisinage 3×3, une borne : une cellule au moins, et jamais plus près que la boîte
+// de la forme.
float2 s_voxels(float3 p, int shape)
{
- float2 o = s_grid_origin(shape);
- float2 cell = floor((p.xy - o) / SCULPT_VOX);
- float2 bc = shape == 0 ? float2(0.33, -0.5) : float2(0.25, -0.47);
- float2 bh = shape == 0 ? float2(0.44, 0.61) : float2(0.48, 0.55);
- float2 bq = max(abs(p.xy - bc) - bh, 0.0);
+ float4 b = PIX_BOX[shape];
+ float2 bq = max(abs(p.xy - b.xy) - b.zw, 0.0);
float bz = max(abs(p.z - (SCULPT_HOVER + 0.07)) - 0.07, 0.0);
- float far = max(SCULPT_VOX, sqrt(dot(bq, bq) + bz * bz));
- float dF = far;
- float dB = far;
- const float3 half_cell = float3(0.5 * SCULPT_VOX, 0.5 * SCULPT_VOX, 0.04);
+ float d = max(SCULPT_VOX, sqrt(dot(bq, bq) + bz * bz));
+ float2 o = PIX_ORIGIN[shape];
+ int2 cell = s_pix_cell(p.xy, shape);
+ const float3 cube = float3(0.5 * SCULPT_VOX, 0.5 * SCULPT_VOX, 0.07);
[loop] for (int j = -1; j <= 1; j++)
{
[loop] for (int i = -1; i <= 1; i++)
{
- float2 id = cell + float2(i, j);
- float3 q = float3(p.xy - (o + (id + 0.5) * SCULPT_VOX), p.z);
- if (s_occ(id, shape))
- {
- dF = min(dF, s_round_box(q - float3(0.0, 0.0, SCULPT_HOVER + 0.1), half_cell, 0.009));
- }
- if (s_occ_back(id, shape))
+ int c = cell.x + i;
+ int r = cell.y + j;
+ if (s_pix_body(shape, c, r))
{
- dB = min(dB, s_round_box(q - float3(0.0, 0.0, SCULPT_HOVER + 0.04), half_cell, 0.009));
+ float3 q = float3(p.x - o.x - (c + 0.5) * SCULPT_VOX, p.y - o.y + (r + 0.5) * SCULPT_VOX,
+ p.z - SCULPT_HOVER - 0.07);
+ d = min(d, s_round_box(q, cube, 0.006));
}
}
}
- return dF < dB ? float2(dF, 6.0) : float2(dB, 7.0);
+ return float2(d, 6.0);
}
-// Distance, dans le plan de l'écran, au contour de la couche violette de la main.
-float s_pixel_hand_dist(float2 p)
+// Distance, dans le plan de l'écran, au contour de la forme.
+float s_pixel_outline(float2 p, int shape)
{
float d = 1e9;
- [loop] for (int r = 0; r < 17; r++)
+ [loop] for (int i = PIX_RECT_N[shape]; i < PIX_RECT_N[shape + 1]; i++)
{
- float2 x = (SCULPT_HANDSPAN[r] + float2(-3.5, -2.5)) * SCULPT_VOX;
- float2 y = float2(-r, 1.0 - r) * SCULPT_VOX;
- float2 q = max(max(float2(x.x, y.x) - p, p - float2(x.y, y.y)), 0.0);
- d = min(d, length(q));
- }
- return d;
-}
-
-// Prism Glow est taillé, pas peint : chaque pièce est un polyèdre convexe (le max de ses plans),
-// les facettes sont planes et la silhouette polygonale.
-
-// Une arête d'un contour convexe de gemme (a -> b, sens trigonométrique) : sa paroi, une facette
-// de rondiste raide, et deux facettes de couronne qui se rejoignent au milieu de l'arête. Les
-// couronnes de toutes les arêtes se rejoignent en arêtes vives au-dessus du centre.
-float s_gem_edge(float3 p, float2 a, float2 b, float z0)
-{
- float2 e = b - a;
- float len = length(e);
- float2 d = e / len;
- float2 q = p.xy - a;
- float dist = dot(q, float2(-d.y, d.x));
- float along = abs(dot(q, d) - 0.5 * len);
- float z = p.z - z0;
- float girdle = (z - 0.035 - 2.2 * dist) * 0.4138;
- float crown = (z - 0.07 - 0.6 * dist + 0.18 * along) * 0.8438;
- return max(-dist, max(girdle, crown));
-}
-
-float s_gem_arrow(float3 p)
-{
- float z0 = SCULPT_HOVER + 0.03;
- float slab = max(p.z - z0 - 0.2, z0 - 0.03 - p.z);
- float head = max(slab, max(s_gem_edge(p, float2(-0.02, 0.03), float2(-0.02, -0.88), z0),
- max(s_gem_edge(p, float2(-0.02, -0.88), float2(0.66, -0.61), z0),
- s_gem_edge(p, float2(0.66, -0.61), float2(-0.02, 0.03), z0))));
- float tail = max(slab, max(max(s_gem_edge(p, float2(0.215, -0.665), float2(0.37, -1.0), z0),
- s_gem_edge(p, float2(0.37, -1.0), float2(0.53, -0.93), z0)),
- max(s_gem_edge(p, float2(0.53, -0.93), float2(0.38, -0.60), z0),
- s_gem_edge(p, float2(0.38, -0.60), float2(0.215, -0.665), z0))));
- return min(head, tail);
-}
-
-// Capsule taillée a -> b : un prisme hexagonal coiffé en b de deux couronnes de facettes
-// décalées et d'une facette sommitale, tous les plans tangents à la sphère du bout.
-float s_facet_capsule(float3 p, float3 a, float3 b, float r, float spin)
-{
- float3 u = normalize(b - a);
- float3 v = normalize(cross(u, float3(0.0, 0.0, 1.0)));
- float3 w = cross(u, v);
- float3 q = p - b;
- float h = dot(q, u);
- float2 rad = float2(dot(q, v), dot(q, w));
- float d = max(h - r, -dot(p - a, u) - r);
- [loop] for (int k = 0; k < 6; k++)
- {
- float an = spin + k * 1.0471976;
- float s = dot(rad, float2(cos(an), sin(an)));
- d = max(d, s - r);
- d = max(d, dot(rad, float2(cos(an + 0.5236), sin(an + 0.5236))) * 0.8660 + h * 0.5 - r);
- d = max(d, s * 0.5 + h * 0.8660 - r);
+ float2 q = abs(p - PIX_RECT[i].xy) - PIX_RECT[i].zw;
+ d = min(d, length(max(q, 0.0)) + min(max(q.x, q.y), 0.0));
}
return d;
}
-// Ellipsoïde taillé : les plans tangents dans dix directions du premier octant, reportées dans
-// les huit. Une pierre aux facettes de taille égale.
-static const float3 SCULPT_FACETS[10] = {
- float3(1.0, 0.0, 0.0), float3(0.0, 1.0, 0.0), float3(0.0, 0.0, 1.0),
- float3(0.7071, 0.7071, 0.0), float3(0.7071, 0.0, 0.7071), float3(0.0, 0.7071, 0.7071),
- float3(0.5774, 0.5774, 0.5774), float3(0.4472, 0.0, 0.8944), float3(0.0, 0.4472, 0.8944),
- float3(0.3015, 0.3015, 0.9045)
+// ---- Prism Glow : un cristal en MAILLAGE ----
+// Les facettes tracées sur l'art 2D du thème (`design/cursors/prism-glow/model`), en triangles dans
+// le repère du MODÈLE lui-même (unité = côté du sprite 128 px, origine au hotspot, y vers le bas,
+// dessus du serti en z = 0). Le serti marine est la silhouette de l'art extrudée sous z = 0, un
+// champ de distance comme les autres sculptés : il donne la couverture, l'ombre portée et l'ombre
+// de contact. Le cristal est lancé de rayons, triangles rangés par boîtes : réfraction par canal
+// (dispersion), réflexions totales internes, sortie par son fond plat vers l'image composée sous
+// le curseur, flous de confidentialité compris (sa copie en t5). Tables : miroir de `prism_mesh.rs` ;
+// un triangle = quatre
+// float4 (v0, drapeaux), (e1, r), (e2, g), (normale sortante, b) ; drapeaux = paroi (1) + 2 ×
+// arêtes réelles (bit k : l'arête opposée au sommet k) ; une boîte = deux float4 (coin bas,
+// premier triangle), (coin haut, nombre de triangles).
+// prism mesh: generated by design/cursors/prism-glow/model/export_compositor.py
+static const int PRISM_TRI_START[2] = { 0, 31 };
+static const int PRISM_TRI_COUNT[2] = { 31, 130 };
+static const int PRISM_SIL_START[2] = { 0, 25 };
+static const int PRISM_SIL_COUNT[2] = { 25, 42 };
+static const int PRISM_OUT_START[2] = { 67, 76 };
+static const int PRISM_OUT_COUNT[2] = { 9, 34 };
+static const int PRISM_BOX_START[2] = { 0, 4 };
+static const int PRISM_BOX_COUNT[2] = { 4, 18 };
+static const float4 PRISM_TRIS[644] = {
+ float4(0.252301, 0.266258, -0.042945, 11.0),
+ float4(-0.221166, -0.203988, 0.0, 0.01033),
+ float4(-0.221166, -0.203988, 0.042945, 0.045182),
+ float4(0.677984, -0.735077, 0.0, 0.577584),
+ float4(0.252301, 0.266258, -0.042945, 3.0),
+ float4(-0.221166, -0.203988, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.677984, -0.735077, 0.0, 0.577584),
+ float4(0.031135, 0.06227, -0.042945, 7.0),
+ float4(0.004294, 0.419785, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(-0.999948, 0.01023, 0.0, 0.577584),
+ float4(0.169632, 0.356442, 0.115951, 14.0),
+ float4(0.082669, -0.090184, -0.115951, 0.590616),
+ float4(-0.138497, -0.294172, -0.115951, 0.737911),
+ float4(0.466385, -0.505659, 0.725806, 0.973449),
+ float4(0.035429, 0.482055, 0.0, 14.0),
+ float4(0.134202, -0.125614, 0.115951, 0.000305),
+ float4(-0.004294, -0.419785, 0.0, 0.938689),
+ float4(-0.650191, 0.006652, 0.759742, 0.854994),
+ float4(0.031135, 0.06227, -0.042945, 9.0),
+ float4(0.004294, 0.419785, 0.0, 0.01033),
+ float4(0.004294, 0.419785, 0.042945, 0.045182),
+ float4(-0.999948, 0.01023, 0.0, 0.577584),
+ float4(0.169632, 0.356442, 0.115951, 14.0),
+ float4(0.159969, 0.080521, -0.045092, 0.381323),
+ float4(0.082669, -0.090184, -0.115951, 0.056123),
+ float4(0.461407, -0.510214, 0.725799, 0.973449),
+ float4(0.541104, 0.544325, -0.042945, 9.0),
+ float4(-0.288804, -0.278068, 0.0, 0.01033),
+ float4(-0.288804, -0.278068, 0.042945, 0.045182),
+ float4(0.69359, -0.72037, 0.0, 0.577584),
+ float4(0.329601, 0.436963, 0.070859, 14.0),
+ float4(0.211503, 0.107362, -0.070859, 0.545724),
+ float4(-0.077301, -0.170706, -0.070859, 0.064805),
+ float4(0.49567, -0.514808, 0.699488, 0.973449),
+ float4(0.268405, 0.533589, 0.081595, 14.0),
+ float4(0.061196, -0.096626, -0.010736, 0.000916),
+ float4(-0.098773, -0.177147, 0.034356, 0.046662),
+ float4(0.247834, 0.049458, 0.967539, 0.904662),
+ float4(0.541104, 0.544325, -0.042945, 7.0),
+ float4(-0.288804, -0.278068, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.69359, -0.72037, 0.0, 0.577584),
+ float4(0.199693, 0.595859, 0.0, 14.0),
+ float4(0.068712, -0.06227, 0.081595, 0.0),
+ float4(-0.030061, -0.239417, 0.115951, 0.01445),
+ float4(-0.504461, 0.426837, 0.750553, 0.254154),
+ float4(0.268405, 0.533589, 0.081595, 14.0),
+ float4(0.272699, 0.010736, -0.081595, 0.0),
+ float4(0.061196, -0.096626, -0.010736, 0.025193),
+ float4(0.283244, 0.073137, 0.956255, 0.527108),
+ float4(0.035429, 0.482055, 0.0, 14.0),
+ float4(0.0, 0.258742, 0.0, 0.0),
+ float4(0.134202, -0.125614, 0.115951, 0.473533),
+ float4(-0.653777, 0.0, 0.756687, 0.964691),
+ float4(0.359663, 0.559356, 0.0, 14.0),
+ float4(0.181442, -0.015031, 0.0, 0.006043),
+ float4(-0.091258, -0.025767, 0.081595, 0.076188),
+ float4(0.076465, 0.923048, 0.37701, 0.964691),
+ float4(0.035429, 0.482055, -0.042945, 3.0),
+ float4(0.0, 0.258742, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(-1.0, 0.0, 0.0, 0.577584),
+ float4(0.035429, 0.482055, -0.042945, 11.0),
+ float4(0.0, 0.258742, 0.0, 0.01033),
+ float4(0.0, 0.258742, 0.042945, 0.045182),
+ float4(-1.0, 0.0, 0.0, 0.577584),
+ float4(0.035429, 0.740798, -0.042945, 7.0),
+ float4(0.164264, -0.144939, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.661622, 0.749838, 0.0, 0.577584),
+ float4(0.035429, 0.740798, 0.0, 14.0),
+ float4(0.164264, -0.144939, 0.0, 0.0),
+ float4(0.134202, -0.384356, 0.115951, 0.010956),
+ float4(0.332572, 0.376915, 0.864483, 0.215854),
+ float4(0.035429, 0.740798, -0.042945, 11.0),
+ float4(0.164264, -0.144939, 0.0, 0.01033),
+ float4(0.164264, -0.144939, 0.042945, 0.045182),
+ float4(0.661622, 0.749838, 0.0, 0.577584),
+ float4(0.199693, 0.595859, -0.042945, 7.0),
+ float4(0.105215, 0.22546, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(-0.906183, 0.422886, 0.0, 0.577584),
+ float4(0.199693, 0.595859, 0.0, 14.0),
+ float4(0.105215, 0.22546, 0.0, 0.0),
+ float4(0.068712, -0.06227, 0.081595, 0.015991),
+ float4(-0.613882, 0.286478, 0.735581, 0.473533),
+ float4(0.199693, 0.595859, -0.042945, 11.0),
+ float4(0.105215, 0.22546, 0.0, 0.01033),
+ float4(0.105215, 0.22546, 0.042945, 0.045182),
+ float4(-0.906183, 0.422886, 0.0, 0.577584),
+ float4(0.304908, 0.821319, 0.0, 14.0),
+ float4(0.128834, -0.067638, 0.0, 0.0),
+ float4(-0.036503, -0.28773, 0.081595, 0.254154),
+ float4(0.133686, 0.254641, 0.957751, 0.973449),
+ float4(0.304908, 0.821319, -0.042945, 7.0),
+ float4(0.128834, -0.067638, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.464834, 0.885398, 0.0, 0.577584),
+ float4(0.433742, 0.753681, 0.0, 14.0),
+ float4(-0.07408, -0.194325, 0.0, 0.0),
+ float4(-0.165337, -0.220092, 0.081595, 0.964691),
+ float4(0.683362, -0.260508, 0.682021, 0.964691),
+ float4(0.433742, 0.753681, -0.042945, 11.0),
+ float4(-0.07408, -0.194325, 0.0, 0.01033),
+ float4(-0.07408, -0.194325, 0.042945, 0.045182),
+ float4(0.934406, -0.35621, 0.0, 0.577584),
+ float4(0.304908, 0.821319, -0.042945, 11.0),
+ float4(0.128834, -0.067638, 0.0, 0.01033),
+ float4(0.128834, -0.067638, 0.042945, 0.045182),
+ float4(0.464834, 0.885398, 0.0, 0.577584),
+ float4(0.433742, 0.753681, -0.042945, 7.0),
+ float4(-0.07408, -0.194325, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.934406, -0.35621, 0.0, 0.577584),
+ float4(0.359663, 0.559356, -0.042945, 7.0),
+ float4(0.181442, -0.015031, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.082557, 0.996586, 0.0, 0.577584),
+ float4(0.359663, 0.559356, -0.042945, 11.0),
+ float4(0.181442, -0.015031, 0.0, 0.01033),
+ float4(0.181442, -0.015031, 0.042945, 0.045182),
+ float4(0.082557, 0.996586, 0.0, 0.577584),
+ float4(0.044345, 0.043263, -0.043263, 11.0),
+ float4(-0.043263, -0.005408, 0.0, 0.01033),
+ float4(-0.043263, -0.005408, 0.043263, 0.045182),
+ float4(0.124035, -0.992278, 0.0, 0.577584),
+ float4(0.044345, 0.043263, -0.043263, 7.0),
+ float4(-0.043263, -0.005408, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.124035, -0.992278, 0.0, 0.577584),
+ float4(0.001082, 0.037855, -0.043263, 7.0),
+ float4(-0.044345, 0.040019, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.669964, -0.742393, 0.0, 0.577584),
+ float4(0.044345, 0.043263, 0.0, 10.0),
+ float4(-0.043263, -0.005408, 0.0, 0.000305),
+ float4(-0.040019, 0.030284, 0.049753, 0.964691),
+ float4(0.101434, -0.811469, 0.575525, 0.973449),
+ float4(0.004326, 0.073548, 0.049753, 14.0),
+ float4(-0.003245, -0.035692, -0.049753, 0.0),
+ float4(-0.04759, 0.004326, -0.049753, 0.955978),
+ float4(-0.580475, -0.643229, 0.499304, 0.896269),
+ float4(0.001082, 0.037855, -0.043263, 11.0),
+ float4(-0.044345, 0.040019, 0.0, 0.01033),
+ float4(-0.044345, 0.040019, 0.043263, 0.045182),
+ float4(-0.669964, -0.742393, 0.0, 0.577584),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.035692, -0.155748, -0.008653, 0.000305),
+ float4(-0.011897, -0.151422, -0.058405, 0.955978),
+ float4(-0.716533, -0.20131, 0.667874, 0.904662),
+ float4(-0.043263, 0.077874, -0.043263, 7.0),
+ float4(-0.001082, 0.154666, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999976, -0.006993, 0.0, 0.577584),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.089771, -0.137361, -0.02055, 0.445197),
+ float4(0.035692, -0.155748, -0.008653, 0.973449),
+ float4(0.216369, -0.004654, 0.976301, 0.973449),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(-0.011897, -0.151422, -0.058405, 0.000305),
+ float4(-0.012979, 0.003245, -0.058405, 0.955978),
+ float4(-0.976245, -0.006827, 0.216564, 0.904662),
+ float4(-0.043263, 0.077874, -0.043263, 9.0),
+ float4(-0.001082, 0.154666, 0.0, 0.01033),
+ float4(-0.001082, 0.154666, 0.043263, 0.045182),
+ float4(-0.999976, -0.006993, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, 0.052998, 14.0),
+ float4(0.014061, -0.335291, 0.005408, 0.520989),
+ float4(0.001082, -0.332046, -0.052998, 0.088655),
+ float4(-0.975941, -0.037462, 0.214794, 0.973449),
+ float4(-0.044345, 0.23254, 0.0, 3.0),
+ float4(0.0, 0.0, -0.043263, 0.01033),
+ float4(-0.001082, 0.332046, -0.043263, 0.045182),
+ float4(-0.999995, -0.003257, 0.0, 0.577584),
+ float4(-0.163319, 0.380717, -0.043263, 7.0),
+ float4(-0.060569, 0.02704, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.407651, -0.913138, 0.0, 0.577584),
+ float4(-0.094098, 0.431551, -0.043263, 11.0),
+ float4(-0.069221, -0.050834, 0.0, 0.01033),
+ float4(-0.069221, -0.050834, 0.043263, 0.045182),
+ float4(0.591909, -0.806004, 0.0, 0.577584),
+ float4(-0.163319, 0.380717, -0.043263, 11.0),
+ float4(-0.060569, 0.02704, 0.0, 0.01033),
+ float4(-0.060569, 0.02704, 0.043263, 0.045182),
+ float4(-0.407651, -0.913138, 0.0, 0.577584),
+ float4(0.075711, 0.08869, -0.043263, 11.0),
+ float4(-0.031366, -0.045426, 0.0, 0.01033),
+ float4(-0.031366, -0.045426, 0.043263, 0.045182),
+ float4(0.822897, -0.568191, 0.0, 0.577584),
+ float4(0.044345, 0.043263, 0.0, 6.0),
+ float4(-0.040019, 0.030284, 0.049753, 0.000305),
+ float4(0.014061, 0.048671, 0.037855, 0.964691),
+ float4(0.365607, -0.634949, 0.680566, 0.973449),
+ float4(0.075711, 0.08869, -0.043263, 7.0),
+ float4(-0.031366, -0.045426, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.822897, -0.568191, 0.0, 0.577584),
+ float4(0.058405, 0.091934, 0.037855, 14.0),
+ float4(0.017305, -0.003245, -0.037855, 0.0),
+ float4(-0.014061, -0.048671, -0.037855, 0.473533),
+ float4(0.757369, -0.522945, 0.39105, 0.904662),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.107077, -0.140606, -0.058405, 0.0),
+ float4(0.089771, -0.137361, -0.02055, 0.376257),
+ float4(0.812052, 0.481346, 0.329966, 0.830777),
+ float4(0.077874, 0.407756, -0.043263, 11.0),
+ float4(-0.002163, -0.319067, 0.0, 0.01033),
+ float4(-0.002163, -0.319067, 0.043263, 0.045182),
+ float4(0.999977, -0.006779, 0.0, 0.577584),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.10924, 0.178461, -0.058405, 0.0),
+ float4(0.107077, -0.140606, -0.058405, 0.376257),
+ float4(0.475583, -0.003224, 0.879665, 0.830777),
+ float4(0.147095, 0.272559, -0.043263, 7.0),
+ float4(-0.035692, 0.034611, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.696146, -0.7179, 0.0, 0.577584),
+ float4(0.147095, 0.272559, -0.043263, 11.0),
+ float4(-0.035692, 0.034611, 0.0, 0.01033),
+ float4(-0.035692, 0.034611, 0.043263, 0.045182),
+ float4(-0.696146, -0.7179, 0.0, 0.577584),
+ float4(0.144932, 0.307169, 0.049753, 14.0),
+ float4(0.002163, -0.034611, -0.049753, 0.0),
+ float4(-0.033529, 0.0, -0.049753, 0.9131),
+ float4(-0.630237, -0.649931, 0.424725, 0.904662),
+ float4(0.183869, 0.317985, 0.037855, 6.0),
+ float4(-0.036774, -0.045426, -0.037855, 0.0),
+ float4(-0.038937, -0.010816, 0.011897, 0.806958),
+ float4(0.374433, -0.753477, 0.540438, 0.854994),
+ float4(0.077874, 0.407756, -0.043263, 7.0),
+ float4(-0.002163, -0.319067, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.999977, -0.006779, 0.0, 0.577584),
+ float4(0.111403, 0.307169, -0.043263, 7.0),
+ float4(-0.002163, 0.131953, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999866, -0.016391, 0.0, 0.577584),
+ float4(0.10924, 0.439122, 0.0, 6.0),
+ float4(0.035692, -0.131953, 0.049753, 0.18117),
+ float4(0.002163, -0.131953, 0.0, 0.955978),
+ float4(-0.82919, -0.013593, 0.558802, 0.964691),
+ float4(0.128708, 0.463999, 0.063813, 14.0),
+ float4(0.055161, -0.146014, -0.025958, 0.246204),
+ float4(0.016224, -0.156829, -0.014061, 0.964691),
+ float4(0.305397, -0.053644, 0.950713, 0.973449),
+ float4(0.111403, 0.307169, -0.043263, 11.0),
+ float4(-0.002163, 0.131953, 0.0, 0.01033),
+ float4(-0.002163, 0.131953, 0.043263, 0.045182),
+ float4(-0.999866, -0.016391, 0.0, 0.577584),
+ float4(0.189277, 0.28013, -0.043263, 11.0),
+ float4(-0.042182, -0.007571, 0.0, 0.01033),
+ float4(-0.042182, -0.007571, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.183869, 0.317985, 0.037855, 10.0),
+ float4(0.005408, -0.037855, -0.037855, 0.0),
+ float4(-0.036774, -0.045426, -0.037855, 0.806958),
+ float4(0.124328, -0.692684, 0.710445, 0.854994),
+ float4(0.189277, 0.28013, -0.043263, 7.0),
+ float4(-0.042182, -0.007571, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.183869, 0.317985, 0.037855, 14.0),
+ float4(0.02704, -0.004326, -0.037855, 0.0),
+ float4(0.005408, -0.037855, -0.037855, 0.514916),
+ float4(0.70062, -0.452013, 0.552101, 0.846876),
+ float4(0.210908, 0.313659, -0.043263, 11.0),
+ float4(-0.021632, -0.033529, 0.0, 0.01033),
+ float4(-0.021632, -0.033529, 0.043263, 0.045182),
+ float4(0.840297, -0.542127, 0.0, 0.577584),
+ float4(0.210908, 0.445612, 0.0, 10.0),
+ float4(0.0, -0.131953, 0.0, 0.0),
+ float4(-0.02704, -0.127627, 0.037855, 0.439658),
+ float4(0.813734, 0.0, 0.581238, 0.871366),
+ float4(0.210908, 0.313659, -0.043263, 7.0),
+ float4(-0.021632, -0.033529, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.840297, -0.542127, 0.0, 0.577584),
+ float4(0.210908, 0.445612, -0.043263, 11.0),
+ float4(0.0, -0.131953, 0.0, 0.01033),
+ float4(0.0, -0.131953, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.241193, 0.343943, -0.043263, 7.0),
+ float4(-0.002163, 0.107077, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999796, -0.020198, 0.0, 0.577584),
+ float4(0.276885, 0.311496, -0.043263, 11.0),
+ float4(-0.035692, 0.032447, 0.0, 0.01033),
+ float4(-0.035692, 0.032447, 0.043263, 0.045182),
+ float4(-0.672673, -0.73994, 0.0, 0.577584),
+ float4(0.23903, 0.45102, 0.0, 14.0),
+ float4(0.042182, -0.108158, 0.046508, 0.000305),
+ float4(0.002163, -0.107077, 0.0, 0.846876),
+ float4(-0.758098, -0.015315, 0.651961, 0.896269),
+ float4(0.276885, 0.311496, -0.043263, 7.0),
+ float4(-0.035692, 0.032447, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.672673, -0.73994, 0.0, 0.577584),
+ float4(0.281211, 0.342862, 0.046508, 14.0),
+ float4(-0.004326, -0.031366, -0.046508, 0.0),
+ float4(-0.040019, 0.001082, -0.046508, 0.752941),
+ float4(-0.58651, -0.645161, 0.489667, 0.846876),
+ float4(0.246601, 0.459672, 0.06165, 14.0),
+ float4(0.070303, -0.101669, -0.024876, 0.262257),
+ float4(0.034611, -0.116811, -0.015142, 0.973449),
+ float4(0.27928, -0.041605, 0.959308, 0.973449),
+ float4(0.319067, 0.319067, -0.043263, 11.0),
+ float4(-0.042182, -0.007571, 0.0, 0.01033),
+ float4(-0.042182, -0.007571, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.319067, 0.319067, 0.0, 10.0),
+ float4(-0.042182, -0.007571, 0.0, 0.0),
+ float4(-0.037855, 0.023795, 0.046508, 0.896269),
+ float4(0.1483, -0.826245, 0.54344, 0.896269),
+ float4(0.319067, 0.319067, -0.043263, 7.0),
+ float4(-0.042182, -0.007571, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.319067, 0.319067, 0.0, 6.0),
+ float4(-0.037855, 0.023795, 0.046508, 0.0),
+ float4(-0.002163, 0.038937, 0.036774, 0.896269),
+ float4(0.437906, -0.604311, 0.665618, 0.896269),
+ float4(0.316904, 0.358004, 0.036774, 14.0),
+ float4(0.024876, -0.004326, -0.036774, 0.0),
+ float4(0.002163, -0.038937, -0.036774, 0.617212),
+ float4(0.707338, -0.464191, 0.533104, 0.921584),
+ float4(0.34178, 0.353677, -0.043263, 11.0),
+ float4(-0.022713, -0.034611, 0.0, 0.01033),
+ float4(-0.022713, -0.034611, 0.043263, 0.045182),
+ float4(0.836048, -0.548657, 0.0, 0.577584),
+ float4(0.340698, 0.474815, 0.0, 14.0),
+ float4(0.001082, -0.121137, 0.0, 0.0),
+ float4(-0.023795, -0.116811, 0.036774, 0.473533),
+ float4(0.828664, 0.007399, 0.559697, 0.930114),
+ float4(0.34178, 0.353677, -0.043263, 7.0),
+ float4(-0.022713, -0.034611, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.836048, -0.548657, 0.0, 0.577584),
+ float4(0.340698, 0.474815, -0.043263, 11.0),
+ float4(0.001082, -0.121137, 0.0, 0.01033),
+ float4(0.001082, -0.121137, 0.043263, 0.045182),
+ float4(0.99996, 0.008928, 0.0, 0.577584),
+ float4(0.411001, 0.351514, -0.043263, 11.0),
+ float4(-0.034611, 0.030284, 0.0, 0.01033),
+ float4(-0.034611, 0.030284, 0.043263, 0.045182),
+ float4(-0.658505, -0.752577, 0.0, 0.577584),
+ float4(0.411001, 0.351514, -0.043263, 7.0),
+ float4(-0.034611, 0.030284, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.658505, -0.752577, 0.0, 0.577584),
+ float4(0.412083, 0.38288, 0.043263, 14.0),
+ float4(-0.001082, -0.031366, -0.043263, 0.0),
+ float4(-0.035692, -0.001082, -0.043263, 0.745403),
+ float4(-0.574039, -0.656045, 0.489984, 0.854994),
+ float4(0.45102, 0.359085, -0.043263, 11.0),
+ float4(-0.040019, -0.007571, 0.0, 0.01033),
+ float4(-0.040019, -0.007571, 0.043263, 0.045182),
+ float4(0.185892, -0.98257, 0.0, 0.577584),
+ float4(0.45102, 0.359085, 0.0, 10.0),
+ float4(-0.040019, -0.007571, 0.0, 0.000305),
+ float4(-0.038937, 0.023795, 0.043263, 0.879622),
+ float4(0.151736, -0.802035, 0.577682, 0.9131),
+ float4(0.45102, 0.359085, -0.043263, 7.0),
+ float4(-0.040019, -0.007571, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.185892, -0.98257, 0.0, 0.577584),
+ float4(0.45102, 0.359085, 0.0, 6.0),
+ float4(-0.038937, 0.023795, 0.043263, 0.000305),
+ float4(0.003245, 0.0411, 0.032447, 0.879622),
+ float4(0.417852, -0.583049, 0.696744, 0.9131),
+ float4(0.473733, 0.391533, -0.043263, 11.0),
+ float4(-0.022713, -0.032447, 0.0, 0.01033),
+ float4(-0.022713, -0.032447, 0.043263, 0.045182),
+ float4(0.819232, -0.573462, 0.0, 0.577584),
+ float4(0.454264, 0.400185, 0.032447, 14.0),
+ float4(0.019468, -0.008653, -0.032447, 0.0),
+ float4(-0.003245, -0.0411, -0.032447, 0.527108),
+ float4(0.688617, -0.482032, 0.541712, 0.887922),
+ float4(0.473733, 0.391533, -0.043263, 7.0),
+ float4(-0.022713, -0.032447, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.819232, -0.573462, 0.0, 0.577584),
+ float4(-0.223888, 0.407756, -0.043263, 7.0),
+ float4(0.002163, 0.06814, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999496, 0.03173, 0.0, 0.577584),
+ float4(-0.223888, 0.407756, -0.043263, 11.0),
+ float4(0.002163, 0.06814, 0.0, 0.01033),
+ float4(0.002163, 0.06814, 0.043263, 0.045182),
+ float4(-0.999496, 0.03173, 0.0, 0.577584),
+ float4(-0.167645, 0.416409, 0.046508, 14.0),
+ float4(-0.056242, -0.008653, -0.046508, 0.000305),
+ float4(-0.054079, 0.059487, -0.046508, 0.955978),
+ float4(-0.638985, 0.020285, 0.768952, 0.973449),
+ float4(-0.167645, 0.416409, 0.046508, 14.0),
+ float4(0.004326, -0.035692, -0.046508, 0.000305),
+ float4(-0.056242, -0.008653, -0.046508, 0.955978),
+ float4(-0.339782, -0.761112, 0.552501, 0.973449),
+ float4(-0.221724, 0.475896, 0.0, 14.0),
+ float4(0.139524, 0.055161, 0.043263, 0.520989),
+ float4(0.054079, -0.059487, 0.046508, 0.973449),
+ float4(-0.39307, 0.317373, 0.863001, 0.973449),
+ float4(-0.167645, 0.416409, 0.046508, 6.0),
+ float4(0.073548, 0.015142, -0.046508, 0.0),
+ float4(0.004326, -0.035692, -0.046508, 0.768154),
+ float4(0.490917, -0.668482, 0.558688, 0.871366),
+ float4(-0.094098, 0.431551, -0.043263, 7.0),
+ float4(-0.069221, -0.050834, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.591909, -0.806004, 0.0, 0.577584),
+ float4(-0.167645, 0.416409, 0.046508, 10.0),
+ float4(0.085445, 0.114648, -0.003245, 0.0),
+ float4(0.073548, 0.015142, -0.046508, 0.768154),
+ float4(0.548356, -0.38771, 0.740936, 0.871366),
+ float4(-0.0822, 0.531057, -0.043263, 11.0),
+ float4(-0.011897, -0.099506, 0.0, 0.01033),
+ float4(-0.011897, -0.099506, 0.043263, 0.045182),
+ float4(0.992928, -0.11872, 0.0, 0.577584),
+ float4(-0.044345, 0.23254, 0.0, 7.0),
+ float4(-0.001082, 0.332046, -0.043263, 0.01033),
+ float4(-0.001082, 0.332046, 0.052998, 0.045182),
+ float4(-0.999995, -0.003257, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, 0.052998, 14.0),
+ float4(0.1233, -0.156829, -0.052998, 0.520989),
+ float4(0.014061, -0.335291, 0.005408, 0.088655),
+ float4(0.429355, 0.032562, 0.902549, 0.973449),
+ float4(0.10924, 0.439122, 0.0, 14.0),
+ float4(-0.031366, -0.031366, 0.0, 0.132876),
+ float4(-0.154666, 0.125464, 0.052998, 0.028428),
+ float4(0.182761, -0.182761, 0.966021, 0.955978),
+ float4(0.10924, 0.439122, -0.043263, 11.0),
+ float4(-0.031366, -0.031366, 0.0, 0.01033),
+ float4(-0.031366, -0.031366, 0.043263, 0.045182),
+ float4(0.707107, -0.707107, 0.0, 0.577584),
+ float4(0.10924, 0.439122, -0.043263, 7.0),
+ float4(-0.031366, -0.031366, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.707107, -0.707107, 0.0, 0.577584),
+ float4(0.10924, 0.439122, 0.0, 10.0),
+ float4(0.019468, 0.024876, 0.063813, 0.18117),
+ float4(0.035692, -0.131953, 0.049753, 0.955978),
+ float4(-0.933936, -0.126584, 0.334276, 0.964691),
+ float4(0.128708, 0.463999, 0.063813, 14.0),
+ float4(0.050834, 0.002163, -0.005408, 0.0),
+ float4(0.0822, -0.018387, -0.063813, 0.439658),
+ float4(0.07859, -0.92643, 0.368172, 0.871366),
+ float4(-0.221724, 0.475896, -0.043263, 7.0),
+ float4(0.122219, 0.207664, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.861819, 0.507216, 0.0, 0.577584),
+ float4(-0.221724, 0.475896, -0.043263, 9.0),
+ float4(0.122219, 0.207664, 0.0, 0.01033),
+ float4(0.122219, 0.207664, 0.043263, 0.045182),
+ float4(-0.861819, 0.507216, 0.0, 0.577584),
+ float4(-0.099506, 0.68356, 0.0, 14.0),
+ float4(0.017305, -0.152503, 0.043263, 0.0),
+ float4(-0.122219, -0.207664, 0.0, 0.723049),
+ float4(-0.365879, 0.215335, 0.905408, 0.854994),
+ float4(-0.0822, 0.531057, -0.043263, 7.0),
+ float4(-0.011897, -0.099506, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.086527, 0.045182),
+ float4(0.992928, -0.11872, 0.0, 0.577584),
+ float4(-0.099506, 0.68356, 0.0, 14.0),
+ float4(0.054079, -0.118974, 0.052998, 0.0),
+ float4(0.017305, -0.152503, 0.043263, 0.514916),
+ float4(-0.41958, 0.203352, 0.884647, 0.863157),
+ float4(-0.099506, 0.68356, -0.043263, 3.0),
+ float4(0.086527, 0.149258, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.86514, 0.501531, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, -0.043263, 11.0),
+ float4(-0.036774, -0.033529, 0.0, 0.01033),
+ float4(-0.036774, -0.033529, 0.086527, 0.045182),
+ float4(0.673754, -0.738956, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, -0.043263, 7.0),
+ float4(-0.036774, -0.033529, 0.086527, 0.01033),
+ float4(0.0, 0.0, 0.096261, 0.045182),
+ float4(0.673754, -0.738956, 0.0, 0.577584),
+ float4(-0.099506, 0.68356, 0.0, 14.0),
+ float4(0.086527, 0.149258, 0.0, 0.0),
+ float4(0.054079, -0.118974, 0.052998, 0.262257),
+ float4(-0.385563, 0.223515, 0.8952, 0.9131),
+ float4(-0.099506, 0.68356, -0.043263, 11.0),
+ float4(0.086527, 0.149258, 0.0, 0.01033),
+ float4(0.086527, 0.149258, 0.043263, 0.045182),
+ float4(-0.86514, 0.501531, 0.0, 0.577584),
+ float4(-0.012979, 0.832818, 0.0, 14.0),
+ float4(0.214153, -0.194685, 0.0822, 0.000305),
+ float4(-0.032447, -0.268232, 0.052998, 0.930114),
+ float4(-0.176863, 0.211325, 0.961281, 0.973449),
+ float4(0.128708, 0.463999, 0.063813, 14.0),
+ float4(-0.019468, -0.024876, -0.063813, 0.168276),
+ float4(-0.174135, 0.100587, -0.010816, 0.038209),
+ float4(-0.469238, -0.764881, 0.441331, 0.955978),
+ float4(0.201174, 0.638133, 0.0822, 14.0),
+ float4(-0.072466, -0.174135, -0.018387, 0.18117),
+ float4(-0.246601, -0.073548, -0.029203, 0.082277),
+ float4(-0.098561, -0.063844, 0.993081, 0.973449),
+ float4(-0.012979, 0.832818, -0.043263, 7.0),
+ float4(0.339617, -0.002163, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.006369, 0.99998, 0.0, 0.577584),
+ float4(0.179543, 0.466162, 0.058405, 10.0),
+ float4(-0.050834, -0.002163, 0.005408, 0.000305),
+ float4(0.021632, 0.171972, 0.023795, 0.022171),
+ float4(0.110896, -0.149888, 0.982464, 0.381323),
+ float4(-0.012979, 0.832818, 0.0, 14.0),
+ float4(0.339617, -0.002163, 0.0, 0.0),
+ float4(0.214153, -0.194685, 0.0822, 0.027314),
+ float4(0.002492, 0.391296, 0.920261, 0.49102),
+ float4(0.183869, 0.317985, 0.037855, 10.0),
+ float4(-0.055161, 0.146014, 0.025958, 0.0),
+ float4(0.02704, 0.127627, -0.037855, 0.439658),
+ float4(0.623841, 0.097823, 0.775405, 0.871366),
+ float4(0.210908, 0.445612, -0.043263, 7.0),
+ float4(0.0, -0.131953, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.210908, 0.445612, 0.0, 10.0),
+ float4(-0.031366, 0.02055, 0.058405, 0.000305),
+ float4(0.035692, 0.014061, 0.06165, 0.022171),
+ float4(-0.105864, -0.954447, 0.278971, 0.381323),
+ float4(0.23903, 0.45102, -0.043263, 11.0),
+ float4(-0.028121, -0.005408, 0.0, 0.01033),
+ float4(-0.028121, -0.005408, 0.043263, 0.045182),
+ float4(0.188847, -0.982006, 0.0, 0.577584),
+ float4(0.23903, 0.45102, -0.043263, 7.0),
+ float4(-0.028121, -0.005408, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.188847, -0.982006, 0.0, 0.577584),
+ float4(0.246601, 0.459672, 0.06165, 11.0),
+ float4(-0.007571, -0.008653, -0.06165, 0.000305),
+ float4(-0.035692, -0.014061, -0.06165, 0.022171),
+ float4(0.187618, -0.975617, 0.113888, 0.381323),
+ float4(0.241193, 0.343943, -0.043263, 11.0),
+ float4(-0.002163, 0.107077, 0.0, 0.01033),
+ float4(-0.002163, 0.107077, 0.043263, 0.045182),
+ float4(-0.999796, -0.020198, 0.0, 0.577584),
+ float4(0.23903, 0.45102, 0.0, 15.0),
+ float4(0.007571, 0.008653, 0.06165, 0.000305),
+ float4(0.042182, -0.108158, 0.046508, 0.846876),
+ float4(-0.94107, -0.299262, 0.157572, 0.896269),
+ float4(0.316904, 0.358004, 0.036774, 14.0),
+ float4(-0.070303, 0.101669, 0.024876, 0.0),
+ float4(0.023795, 0.116811, -0.036774, 0.473533),
+ float4(0.523424, 0.157027, 0.837478, 0.930114),
+ float4(0.340698, 0.474815, -0.043263, 7.0),
+ float4(0.001082, -0.121137, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.99996, 0.008928, 0.0, 0.577584),
+ float4(0.376391, 0.381799, -0.043263, 11.0),
+ float4(-0.001082, 0.107077, 0.0, 0.01033),
+ float4(-0.001082, 0.107077, 0.043263, 0.045182),
+ float4(-0.999949, -0.0101, 0.0, 0.577584),
+ float4(0.376391, 0.381799, -0.043263, 7.0),
+ float4(-0.001082, 0.107077, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999949, -0.0101, 0.0, 0.577584),
+ float4(0.375309, 0.488875, 0.0, 14.0),
+ float4(0.036774, -0.105995, 0.043263, 0.000305),
+ float4(0.001082, -0.107077, 0.0, 0.879622),
+ float4(-0.771254, -0.00779, 0.63648, 0.896269),
+ float4(0.375309, 0.488875, 0.0, 14.0),
+ float4(0.078955, -0.08869, 0.032447, 0.223224),
+ float4(0.036774, -0.105995, 0.043263, 0.964691),
+ float4(0.071225, 0.39802, 0.914608, 0.973449),
+ float4(0.473733, 0.603523, 0.0, 14.0),
+ float4(0.0, -0.21199, 0.0, 0.158953),
+ float4(-0.019468, -0.203337, 0.032447, 0.030716),
+ float4(0.857493, 0.0, 0.514496, 0.964691),
+ float4(0.473733, 0.603523, -0.043263, 11.0),
+ float4(0.0, -0.21199, 0.0, 0.01033),
+ float4(0.0, -0.21199, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.276885, 0.484549, 0.058405, 10.0),
+ float4(-0.030284, -0.024876, 0.003245, 0.000305),
+ float4(-0.097342, -0.018387, 0.0, 0.022171),
+ float4(-0.03153, 0.166923, 0.985466, 0.381323),
+ float4(0.246601, 0.459672, 0.06165, 14.0),
+ float4(0.030284, 0.024876, -0.003245, 0.0),
+ float4(0.094098, 0.015142, -0.06165, 0.473533),
+ float4(0.518869, -0.545855, 0.657888, 0.930114),
+ float4(0.375309, 0.488875, -0.043263, 11.0),
+ float4(-0.034611, -0.014061, 0.0, 0.01033),
+ float4(-0.034611, -0.014061, 0.043263, 0.045182),
+ float4(0.376377, -0.926467, 0.0, 0.577584),
+ float4(0.340698, 0.474815, 0.0, 14.0),
+ float4(-0.063813, 0.009734, 0.058405, 0.000305),
+ float4(0.034611, 0.014061, 0.0, 0.022171),
+ float4(0.3276, -0.806402, 0.492334, 0.381323),
+ float4(0.375309, 0.488875, -0.043263, 7.0),
+ float4(-0.034611, -0.014061, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.376377, -0.926467, 0.0, 0.577584),
+ float4(0.454264, 0.400185, 0.032447, 12.0),
+ float4(-0.078955, 0.08869, -0.032447, 0.158953),
+ float4(0.019468, 0.203337, -0.032447, 0.030716),
+ float4(-0.201684, 0.173144, 0.964025, 0.964691),
+ float4(0.201174, 0.638133, 0.0822, 8.0),
+ float4(0.075711, -0.153585, -0.023795, 0.000305),
+ float4(-0.021632, -0.171972, -0.023795, 0.022171),
+ float4(0.026497, -0.140281, 0.989757, 0.381323),
+ float4(0.473733, 0.603523, -0.043263, 7.0),
+ float4(0.0, -0.21199, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.276885, 0.484549, 0.058405, 14.0),
+ float4(-0.075711, 0.153585, 0.023795, 0.000305),
+ float4(0.098424, 0.004326, -0.058405, 0.022171),
+ float4(0.50316, 0.115345, 0.856461, 0.381323),
+ float4(0.375309, 0.488875, 0.0, 10.0),
+ float4(0.090853, 0.12979, 0.0, 0.158953),
+ float4(0.098424, 0.114648, 0.0, 0.030716),
+ float4(0.0, 0.0, 1.0, 0.964691),
+ float4(0.466162, 0.618665, -0.043263, 11.0),
+ float4(0.007571, -0.015142, 0.0, 0.01033),
+ float4(0.007571, -0.015142, 0.043263, 0.045182),
+ float4(0.894427, 0.447214, 0.0, 0.577584),
+ float4(0.466162, 0.618665, -0.043263, 3.0),
+ float4(0.007571, -0.015142, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.894427, 0.447214, 0.0, 0.577584),
+ float4(0.326638, 0.830655, 0.0, 14.0),
+ float4(0.139524, -0.21199, 0.0, 0.0),
+ float4(0.048671, -0.34178, 0.0, 0.015213),
+ float4(0.0, 0.0, 1.0, 0.428687),
+ float4(0.326638, 0.830655, 0.0, 14.0),
+ float4(0.048671, -0.34178, 0.0, 0.007492),
+ float4(-0.125464, -0.192522, 0.0822, 0.054475),
+ float4(0.472493, 0.067285, 0.878762, 0.964691),
+ float4(0.326638, 0.830655, -0.043263, 9.0),
+ float4(0.139524, -0.21199, 0.0, 0.01033),
+ float4(0.139524, -0.21199, 0.043263, 0.045182),
+ float4(0.835314, 0.549773, 0.0, 0.577584),
+ float4(0.326638, 0.830655, -0.043263, 7.0),
+ float4(0.139524, -0.21199, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.835314, 0.549773, 0.0, 0.577584),
+ float4(-0.012979, 0.832818, -0.043263, 11.0),
+ float4(0.339617, -0.002163, 0.0, 0.01033),
+ float4(0.339617, -0.002163, 0.043263, 0.045182),
+ float4(0.006369, 0.99998, 0.0, 0.577584)
};
+static const float4 PRISM_BOXES[44] = {
+ float4(0.030135, 0.06127, -0.043945, 0.0),
+ float4(0.330601, 0.483055, 0.116951, 7.0),
+ float4(0.034429, 0.265258, -0.043945, 7.0),
+ float4(0.542104, 0.741798, 0.116951, 8.0),
+ float4(0.034429, 0.355442, -0.043945, 15.0),
+ float4(0.305908, 0.822319, 0.116951, 8.0),
+ float4(0.267405, 0.532589, -0.043945, 23.0),
+ float4(0.542104, 0.822319, 0.082595, 8.0),
+ float4(-0.045345, 0.036855, -0.044263, 31.0),
+ float4(0.045345, 0.23354, 0.059405, 8.0),
+ float4(-0.224888, 0.072548, -0.044263, 39.0),
+ float4(0.059405, 0.565586, 0.059405, 8.0),
+ float4(-0.032366, 0.042263, -0.044263, 47.0),
+ float4(0.148095, 0.408756, 0.059405, 8.0),
+ float4(0.074711, 0.08769, -0.044263, 55.0),
+ float4(0.184869, 0.464999, 0.064813, 8.0),
+ float4(0.146095, 0.271559, -0.044263, 63.0),
+ float4(0.211908, 0.446612, 0.038855, 8.0),
+ float4(0.23803, 0.310496, -0.044263, 71.0),
+ float4(0.320067, 0.460672, 0.06265, 8.0),
+ float4(0.275885, 0.310496, -0.044263, 79.0),
+ float4(0.412001, 0.475815, 0.047508, 8.0),
+ float4(0.375391, 0.350514, -0.044263, 87.0),
+ float4(0.45202, 0.38388, 0.044263, 4.0),
+ float4(0.410001, 0.350514, -0.044263, 91.0),
+ float4(0.474733, 0.401185, 0.044263, 5.0),
+ float4(-0.224888, 0.379717, -0.044263, 96.0),
+ float4(-0.0812, 0.532057, 0.047508, 8.0),
+ float4(-0.095098, 0.228295, -0.044263, 104.0),
+ float4(0.211908, 0.565586, 0.064813, 8.0),
+ float4(-0.222724, 0.430551, -0.044263, 112.0),
+ float4(-0.011979, 0.833818, 0.053998, 8.0),
+ float4(-0.100506, 0.438122, -0.044263, 120.0),
+ float4(0.327638, 0.833818, 0.0832, 8.0),
+ float4(0.127708, 0.312659, -0.044263, 128.0),
+ float4(0.282211, 0.467162, 0.064813, 8.0),
+ float4(0.245601, 0.352677, -0.044263, 136.0),
+ float4(0.474733, 0.604523, 0.06265, 8.0),
+ float4(0.178543, 0.390533, -0.044263, 144.0),
+ float4(0.474733, 0.639133, 0.0832, 8.0),
+ float4(0.200174, 0.483549, -0.044263, 152.0),
+ float4(0.474733, 0.639133, 0.0832, 4.0),
+ float4(-0.013979, 0.487875, -0.044263, 156.0),
+ float4(0.467162, 0.833818, 0.0832, 5.0)
+};
+static const float2 PRISM_POLY[110] = {
+ float2(0.006442, -0.002147),
+ float2(-0.001074, 0.001074),
+ float2(-0.004294, 0.007515),
+ float2(-0.004294, 0.806288),
+ float2(-0.001074, 0.809509),
+ float2(0.004294, 0.809509),
+ float2(0.022546, 0.796626),
+ float2(0.185736, 0.655982),
+ float2(0.281288, 0.86319),
+ float2(0.286656, 0.870706),
+ float2(0.293098, 0.871779),
+ float2(0.465951, 0.781595),
+ float2(0.478834, 0.773006),
+ float2(0.478834, 0.763344),
+ float2(0.407975, 0.592638),
+ float2(0.45092, 0.586196),
+ float2(0.601227, 0.573313),
+ float2(0.606595, 0.569018),
+ float2(0.606595, 0.564724),
+ float2(0.601227, 0.556135),
+ float2(0.50138, 0.453067),
+ float2(0.425153, 0.37684),
+ float2(0.289877, 0.246933),
+ float2(0.081595, 0.057975),
+ float2(0.013957, 0.0),
+ float2(-0.011897, 0.0),
+ float2(-0.055161, 0.037855),
+ float2(-0.081119, 0.063813),
+ float2(-0.083282, 0.06814),
+ float2(-0.084363, 0.38288),
+ float2(-0.089771, 0.38288),
+ float2(-0.158993, 0.33529),
+ float2(-0.166564, 0.336372),
+ float2(-0.250927, 0.379635),
+ float2(-0.261743, 0.388288),
+ float2(-0.263906, 0.461836),
+ float2(-0.262824, 0.486712),
+ float2(-0.173053, 0.638133),
+ float2(-0.044345, 0.860939),
+ float2(-0.036774, 0.867429),
+ float2(-0.024876, 0.871755),
+ float2(0.33529, 0.871755),
+ float2(0.349351, 0.867429),
+ float2(0.363412, 0.853368),
+ float2(0.513752, 0.619747),
+ float2(0.513752, 0.379635),
+ float2(0.466162, 0.322311),
+ float2(0.461836, 0.320148),
+ float2(0.399104, 0.311496),
+ float2(0.393696, 0.313659),
+ float2(0.374227, 0.332046),
+ float2(0.343943, 0.288782),
+ float2(0.336372, 0.282293),
+ float2(0.269314, 0.271477),
+ float2(0.263906, 0.27364),
+ float2(0.243356, 0.292027),
+ float2(0.21199, 0.244438),
+ float2(0.207664, 0.241193),
+ float2(0.149258, 0.231459),
+ float2(0.138443, 0.231459),
+ float2(0.118974, 0.248764),
+ float2(0.116811, 0.222806),
+ float2(0.117892, 0.162237),
+ float2(0.115729, 0.076792),
+ float2(0.06814, 0.011897),
+ float2(0.062732, 0.007571),
+ float2(0.005408, 0.0),
+ float2(0.031135, 0.06227),
+ float2(0.252301, 0.266258),
+ float2(0.541104, 0.544325),
+ float2(0.359663, 0.559356),
+ float2(0.433742, 0.753681),
+ float2(0.304908, 0.821319),
+ float2(0.199693, 0.595859),
+ float2(0.035429, 0.740798),
+ float2(0.035429, 0.482055),
+ float2(-0.043263, 0.077874),
+ float2(0.001082, 0.037855),
+ float2(0.044345, 0.043263),
+ float2(0.075711, 0.08869),
+ float2(0.077874, 0.407756),
+ float2(0.10924, 0.439122),
+ float2(0.111403, 0.307169),
+ float2(0.147095, 0.272559),
+ float2(0.189277, 0.28013),
+ float2(0.210909, 0.313659),
+ float2(0.210909, 0.445612),
+ float2(0.23903, 0.45102),
+ float2(0.241193, 0.343943),
+ float2(0.276885, 0.311496),
+ float2(0.319067, 0.319067),
+ float2(0.34178, 0.353677),
+ float2(0.340698, 0.474815),
+ float2(0.375309, 0.488875),
+ float2(0.376391, 0.381799),
+ float2(0.411001, 0.351514),
+ float2(0.45102, 0.359085),
+ float2(0.473733, 0.391533),
+ float2(0.473733, 0.603523),
+ float2(0.466162, 0.618665),
+ float2(0.326638, 0.830655),
+ float2(-0.012979, 0.832818),
+ float2(-0.099506, 0.68356),
+ float2(-0.221724, 0.475896),
+ float2(-0.223888, 0.407756),
+ float2(-0.163319, 0.380717),
+ float2(-0.094098, 0.431551),
+ float2(-0.0822, 0.531057),
+ float2(-0.045426, 0.564586),
+ float2(-0.044345, 0.23254)
+};
+static const float PRISM_BEVEL = 0.012883;
+// end of the prism mesh
+
+// Le verre validé sur le prototype (30/09/2026).
+static const float PRISM_IOR = 1.61;
+static const float PRISM_DISPERSION = 0.035;
+static const float PRISM_GLOW = 0.45;
+static const float PRISM_FOLD = 0.35;
+// Rebonds au plus dans le cristal, par canal ; l'id de matière du cristal.
+static const int PRISM_BOUNCES = 6;
+static const float PRISM_MAT = 9.0;
+
+// Le modèle de Prism Glow de ce dessin : 0 flèche, 1 main, -1 un autre curseur.
+int prism_shape()
+{
+ int id = sculpt_id();
+ return id == 3 || id == 4 ? id - 3 : -1;
+}
-float s_facet_ellipsoid(float3 p, float3 r)
+// Distance signée au polygone `n` sommets de PRISM_POLY à partir de `i0` (plan xy du modèle),
+// négative dedans : la silhouette du serti, ou le contour du cristal.
+float prism_poly_dist(float2 p, int i0, int n)
{
- float3 q = abs(p);
- float d = -1e9;
- [loop] for (int i = 0; i < 10; i++)
+ float2 vj = PRISM_POLY[i0 + n - 1];
+ float d = 1e9;
+ float sgn = 1.0;
+ [loop] for (int i = 0; i < n; i++)
{
- d = max(d, dot(q, SCULPT_FACETS[i]) - length(r * SCULPT_FACETS[i]));
+ float2 vi = PRISM_POLY[i0 + i];
+ float2 e = vj - vi;
+ float2 w = p - vi;
+ float2 b = w - e * saturate(dot(w, e) / dot(e, e));
+ d = min(d, dot(b, b));
+ bool c0 = p.y >= vi.y;
+ bool c1 = p.y < vj.y;
+ bool c2 = e.x * w.y > e.y * w.x;
+ if ((c0 && c1 && c2) || (!c0 && !c1 && !c2))
+ {
+ sgn = -sgn;
+ }
+ vj = vi;
}
- return d;
+ return sgn * sqrt(d);
}
-// Le gant des autres thèmes, mêmes os, taillé en facettes.
-float s_crystal_hand(float3 p)
+// Le serti : la silhouette extrudée de z = -épaisseur à 0, arêtes arrondies de PRISM_BEVEL.
+float prism_rim(float3 p, int shape)
{
- float zc = SCULPT_HAND_ZC;
- float d = s_facet_ellipsoid(p - float3(0.185, -0.70, zc), float3(0.3, 0.235, 0.125));
- d = min(d, s_facet_capsule(p, float3(0.0, -0.52, zc), float3(0.0, -0.10, zc), 0.098, 0.3));
- d = min(d, s_facet_capsule(p, float3(0.17, -0.57, zc + 0.012), float3(0.17, -0.41, zc + 0.045), 0.086, 0.1));
- d = min(d, s_facet_capsule(p, float3(0.31, -0.59, zc + 0.01), float3(0.31, -0.45, zc + 0.04), 0.08, 0.5));
- d = min(d, s_facet_capsule(p, float3(0.435, -0.625, zc + 0.005), float3(0.435, -0.52, zc + 0.03), 0.07, 0.2));
- return min(d, s_facet_capsule(p, float3(0.03, -0.77, zc + 0.03), float3(-0.165, -0.60, zc + 0.065), 0.082, 0.7));
+ float half_t = model_thick() * 0.5;
+ float sil = prism_poly_dist(p.xy, PRISM_SIL_START[shape], PRISM_SIL_COUNT[shape]);
+ float2 w = float2(sil + PRISM_BEVEL, abs(p.z + half_t) - (half_t - PRISM_BEVEL));
+ return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - PRISM_BEVEL;
}
-// Le modèle `shape` du thème `theme`, repère du prototype : (distance, id de matière).
-float2 sculpt_proto(float3 p, int theme, int shape)
+// Distance le long de (ro, rd) au triangle `k` (Möller–Trumbore), -1 s'il le rate. Un rien de jeu
+// sur les bords : un rayon ne passe pas entre deux facettes voisines.
+float prism_tri(float3 ro, float3 rd, int k)
{
- if (theme == 3)
+ float3 v0 = PRISM_TRIS[4 * k].xyz;
+ float3 e1 = PRISM_TRIS[4 * k + 1].xyz;
+ float3 e2 = PRISM_TRIS[4 * k + 2].xyz;
+ float3 pv = cross(rd, e2);
+ float det = dot(e1, pv);
+ if (abs(det) < 1e-10)
{
- return s_voxels(p, shape);
+ return -1.0;
}
- if (theme == 1)
+ float inv = 1.0 / det;
+ float3 sv = ro - v0;
+ float u = dot(sv, pv) * inv;
+ float3 qv = cross(sv, e1);
+ float v = dot(rd, qv) * inv;
+ if (u < -2e-4 || v < -2e-4 || u + v > 1.0004)
{
- return float2(shape == 0 ? s_gem_arrow(p) : s_crystal_hand(p), 8.0);
+ return -1.0;
}
- float2 body;
- float3 star;
- if (shape == 0)
+ return dot(e2, qv) * inv;
+}
+
+// Le triangle du cristal `shape` le plus proche devant (ro, rd) : sa distance (1e9 si aucun), son
+// indice dans `hit` (-1 si aucun). Les triangles sont rangés par voisinage, une boîte englobante
+// par groupe (PRISM_BOXES) : un rayon qui rate une boîte, ou n'y entre qu'au-delà du meilleur
+// point, saute tous ses triangles. L'inverse de la direction sert à toutes les boîtes.
+float prism_trace(float3 ro, float3 rd, int shape, out int hit)
+{
+ float3 inv = 1.0 / (abs(rd) > 1e-6 ? rd : 1e-6);
+ int b0 = PRISM_BOX_START[shape];
+ int nb = PRISM_BOX_COUNT[shape];
+ float best = 1e9;
+ hit = -1;
+ [loop] for (int j = b0; j < b0 + nb; j++)
{
- float h = 0.075, re = 0.03, dome = 0.0;
- if (theme == 2)
+ float4 lo = PRISM_BOXES[2 * j];
+ float4 hi = PRISM_BOXES[2 * j + 1];
+ float3 t0 = (lo.xyz - ro) * inv;
+ float3 t1 = (hi.xyz - ro) * inv;
+ float3 tn = min(t0, t1);
+ float3 tf = max(t0, t1);
+ float tmin = max(max(tn.x, tn.y), tn.z);
+ float tmax = min(min(tf.x, tf.y), tf.z);
+ if (tmax < max(tmin, 1e-5) || tmin > best)
{
- h = 0.07;
- re = 0.06;
- dome = 0.035;
+ continue;
}
- if (theme == 4)
+ int k0 = (int)lo.w;
+ int n = (int)hi.w;
+ [loop] for (int i = k0; i < k0 + n; i++)
{
- h = 0.07;
- re = 0.055;
- dome = 0.025;
- }
- float2 r = float2(s_arrow_solid(p, h, re, dome), 1.0);
- if (theme == 0)
- {
- r = s_opu(r, float2(s_piping(p, SCULPT_HOVER + 2.0 * h - 0.004), 2.0));
+ float t = prism_tri(ro, rd, i);
+ if (t > 1e-5 && t < best)
+ {
+ best = t;
+ hit = i;
+ }
}
- body = r;
- star = float3(0.57, -0.86, SCULPT_HOVER + 2.0 * h + dome);
}
- else
+ return best;
+}
+
+// Le modèle `shape` du thème `theme`, repère du prototype : (distance, id de matière).
+float2 sculpt_proto(float3 p, int theme, int shape)
+{
+ if (theme == 3)
{
- body = s_opu(float2(s_glove(p, SCULPT_HAND_ZC), 1.0), float2(s_cuff(p, SCULPT_HAND_ZC), 2.0));
- star = float3(0.185, -0.93, SCULPT_HAND_ZC + 0.15);
+ return s_voxels(p, shape);
}
- // L'étoile de Star Sprout : un seul appel pour les deux formes (FXC recopie chaque appel).
- return theme == 4 ? s_opu(body, s_sprout(p, star)) : body;
+ // Un seul appel pour les trois thèmes cerclés et leurs deux formes (FXC recopie chaque appel).
+ return s_rimmed(p, theme, shape);
}
// Repère du modèle -> prototype. L'écrasement du clic ne raccourcit que z, autour du hotspot.
@@ -1005,23 +2052,28 @@ float sculpt_units()
// Le curseur sculpté en `q` (repère du modèle) : distance et id de matière. `occ` : ce qui porte
// l'ombre sur l'écran. La borne des plans d'une gemme et le champ des voxels sont de mauvaises
// distances loin de la surface : la pénombre s'y strie, ou s'arrête net au bord de la boîte des
-// voxels. Le cristal porte donc l'ombre de la forme lisse de Pop Coral, les voxels celle de leur
-// contour extrudé sur toute leur hauteur.
+// voxels. Pixel Candy porte donc l'ombre de sa forme, extrudée sur la hauteur des cubes. Prism
+// Glow n'a ici que son serti, dans le repère du modèle : son cristal est tracé à part
+// (`prism_primary`), et l'ombre est celle de toute sa silhouette.
float2 sculpt_eval(float3 q, bool occ)
{
int id = sculpt_id() - 1;
int theme = id / 2, shape = id % 2;
+ if (theme == 1)
+ {
+ return float2(prism_rim(q, shape), 8.0);
+ }
float3 p = sculpt_point(q);
float2 r;
if (occ && theme == 3)
{
- float d2 = shape == 0 ? s_arrow2(p.xy) - 1.2 * SCULPT_VOX : s_pixel_hand_dist(p.xy);
+ float d2 = s_pixel_outline(p.xy, shape);
float dz = abs(p.z - (SCULPT_HOVER + 0.07)) - 0.07;
r = float2(length(max(float2(d2, dz), 0.0)) + min(max(d2, dz), 0.0), 7.0);
}
else
{
- r = sculpt_proto(p, occ && theme == 1 ? 2 : theme, shape);
+ r = sculpt_proto(p, theme, shape);
}
return float2(r.x * sculpt_units(), r.y);
}
@@ -1138,20 +2190,16 @@ SculptMat s_mat(float3 alb, float rough, float spec, float sss, float refl)
return m;
}
-// Couleur de face du voxel qui porte `p` (prototype) : menthe où le bord regarde en bas à gauche,
-// rose pâle où il regarde en haut à droite, rose dedans.
+// Couleur du cube qui porte `p` (prototype) : celle de son pixel.
float3 s_pixel_colour(float3 p, int shape)
{
- float2 id = floor((p.xy - s_grid_origin(shape)) / SCULPT_VOX);
- if (!s_occ(id + float2(-1.0, 0.0), shape) || !s_occ(id + float2(0.0, -1.0), shape))
- {
- return s_lin(0.52, 0.91, 0.77);
- }
- if (!s_occ(id + float2(1.0, 0.0), shape) || !s_occ(id + float2(0.0, 1.0), shape))
- {
- return s_lin(1.0, 0.78, 0.87);
- }
- return s_lin(1.0, 0.50, 0.71);
+ int2 cell = s_pix_cell(p.xy, shape);
+ int4 g = PIX_GRID[shape];
+ int row = g.z + clamp(cell.y, 0, g.y - 1);
+ if (s_pix_bit(PIX_LINE[row], cell.x)) return s_lin(0.29, 0.12, 0.36);
+ if (s_pix_bit(PIX_HI[row], cell.x)) return s_lin(1.0, 0.78, 0.87);
+ if (s_pix_bit(PIX_SHADE[row], cell.x)) return s_lin(0.87, 0.27, 0.51);
+ return s_lin(1.0, 0.435, 0.66);
}
// La matière `mat` (id de `sculpt_proto`) au point `p` du prototype.
@@ -1160,28 +2208,36 @@ SculptMat sculpt_material(float mat, float3 p, int theme, int shape)
bool primary = mat < 1.5;
if (theme == 0)
{
- if (shape == 0 && primary) return s_mat(s_lin(0.10, 0.10, 0.115), 0.3, 0.2, 0.0, 0.9);
- if (shape == 0) return s_mat(s_lin(0.94, 0.91, 0.84), 0.45, 0.4, 0.2, 0.3);
- if (primary) return s_mat(s_lin(0.95, 0.92, 0.85), 0.55, 0.35, 0.35, 0.25);
- return s_mat(s_lin(0.17, 0.18, 0.22), 0.35, 0.6, 0.0, 0.6);
+ // Ivoire (le gant, la bande de la flèche) ; noir satiné, presque sans reflet : un reflet
+ // large sur le champ plat de la flèche le virait au gris.
+ if (mat < 2.5) return s_mat(s_lin(0.95, 0.92, 0.85), 0.3, 0.6, 0.35, 0.25);
+ return s_mat(s_lin(0.1, 0.1, 0.11), 0.55, 0.05, 0.0, 0.1);
+ }
+ if (theme == 1)
+ {
+ // Le serti de Prism Glow : un émail marine, brillant sur son arrondi seulement (`model_shade`).
+ return s_mat(s_lin(0.02, 0.05, 0.33), 0.35, 0.5, 0.05, 0.3);
}
if (theme == 2)
{
- if (shape == 0) return s_mat(s_lin(1.0, 0.40, 0.30), 0.5, 0.45, 0.4, 0.25);
- if (primary) return s_mat(s_lin(1.0, 0.79, 0.16), 0.5, 0.45, 0.4, 0.25);
- return s_mat(s_lin(0.18, 0.20, 0.29), 0.4, 0.5, 0.0, 0.4);
+ // Corail : la flèche, le calque et les tirets de la main ; jaune : le gant, le calque et les
+ // tirets de la flèche ; le trait marine.
+ if ((primary && shape == 0) || (mat > 2.5 && mat < 3.5 && shape == 1)) return s_mat(s_lin(1.0, 0.40, 0.30), 0.85, 0.08, 0.15, 0.03);
+ if (mat < 3.5) return s_mat(s_lin(1.0, 0.80, 0.10), 0.85, 0.08, 0.15, 0.03);
+ return s_mat(s_lin(0.09, 0.13, 0.45), 0.9, 0.05, 0.05, 0.02);
}
if (theme == 4)
{
- if (primary && shape == 0) return s_mat(s_lin(0.62, 0.91, 0.78), 0.22, 0.8, 0.25, 0.6);
- if (primary) return s_mat(s_lin(0.96, 0.94, 0.88), 0.5, 0.35, 0.35, 0.25);
- if (mat < 2.5) return s_mat(s_lin(0.62, 0.91, 0.78), 0.25, 0.7, 0.25, 0.5);
- if (mat < 3.5) return s_mat(s_lin(1.0, 0.80, 0.20), 0.3, 0.6, 0.3, 0.4);
- if (mat < 4.5) return s_mat(s_lin(0.38, 0.80, 0.55), 0.35, 0.5, 0.35, 0.3);
- return s_mat(s_lin(0.16, 0.12, 0.10), 0.2, 0.8, 0.0, 0.5);
+ // Coussins laqués ; le marine satiné, un reflet large et sombre plutôt qu'un filet blanc.
+ if (primary && shape == 0) return s_mat(s_lin(0.68, 0.93, 0.80), 0.25, 0.7, 0.3, 0.3);
+ if (primary) return s_mat(s_lin(0.97, 0.95, 0.90), 0.3, 0.6, 0.35, 0.25);
+ if (mat < 2.5) return s_mat(s_lin(0.52, 0.87, 0.78), 0.25, 0.7, 0.3, 0.3);
+ if (mat < 3.5) return s_mat(s_lin(1.0, 0.75, 0.25), 0.25, 0.7, 0.3, 0.3);
+ if (mat < 4.5) return s_mat(s_lin(0.62, 0.92, 0.72), 0.3, 0.6, 0.3, 0.35);
+ return s_mat(s_lin(0.07, 0.15, 0.33), 0.7, 0.15, 0.05, 0.08);
}
- if (mat < 6.5) return s_mat(s_pixel_colour(p, shape), 0.45, 0.35, 0.15, 0.2);
- return s_mat(s_lin(0.36, 0.18, 0.54), 0.45, 0.35, 0.1, 0.2);
+ // Plastique mat : sur les faces plates des cubes, un reflet de la lampe blanchirait le prune.
+ return s_mat(s_pixel_colour(p, shape), 0.65, 0.1, 0.12, 0.04);
}
// L'environnement du studio vu du modèle : l'écran dessous, la pièce au-dessus (z du modèle), une
@@ -1197,39 +2253,6 @@ float3 model_env(float3 d, float rough, float3 l, float3 fill)
return col;
}
-// Cyan, bleu, violet, orchidée.
-float3 s_gem(float k)
-{
- k = saturate(k) * 3.0;
- float3 a = s_lin(0.20, 0.95, 1.0);
- float3 b = s_lin(0.15, 0.42, 1.0);
- float3 c = s_lin(0.45, 0.25, 0.95);
- float3 d = s_lin(0.88, 0.50, 1.0);
- if (k < 1.0) return lerp(a, b, k);
- if (k < 2.0) return lerp(b, c, k - 1.0);
- return lerp(c, d, k - 2.0);
-}
-
-// Le cristal : la teinte vient d'où la facette regarde et d'où elle plie la vue (lues y vers le
-// haut, comme au prototype) ; la dispersion sépare le décalage par canal. Les facettes tournées
-// vers la lampe sont cyan, les autres violettes.
-float3 model_shade_crystal(float3 n, float3 rd, float3 L, float fall, float3 l, float3 fill)
-{
- float cosi = saturate(dot(-rd, n));
- float F = 0.04 + 0.96 * pow(1.0 - cosi, 5.0);
- float3 t = refract(rd, n, 1.0 / 1.6);
- float k = 0.42 + 0.9 * dot(float2(n.x, -n.y), float2(0.7557, -0.6549))
- + 0.6 * dot(float2(t.x, -t.y), float2(0.6, -0.8));
- float3 body = float3(s_gem(k - 0.08).r, s_gem(k).g, s_gem(k + 0.08).b);
- float3 col = body * (0.1 + 1.8 * fall * pow(max(dot(n, L), 0.0), 2.5));
- // Éclat : la vue rebondit sur le dos plat et allume la facette quand elle trouve la lampe.
- col += body * model_env(reflect(t, float3(0.0, 0.0, 1.0)) * float3(1.0, 1.0, -1.0), 0.15, l, fill) * 0.5;
- col += pow(max(dot(reflect(rd, n), L), 0.0), 30.0) * 2.0;
- col += model_env(reflect(rd, n), 0.05, l, fill) * F;
- col += s_lin(0.5, 0.9, 1.0) * pow(1.0 - cosi, 4.0) * 0.6;
- return col;
-}
-
// Khronos PBR Neutral : garde les teintes, ne comprime que les hautes lumières ; puis sRGB.
float3 model_tonemap(float3 c)
{
@@ -1260,10 +2283,6 @@ float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float sh
float3 l, float3 fill)
{
int id = sculpt_id();
- if (id > 0 && (id - 1) / 2 == 1)
- {
- return model_tonemap(model_shade_crystal(n, rd, L, fall, l, fill));
- }
SculptMat m;
// Sur un sprite, reflets et brillance sur les arrondis seulement : le dessus plat d'un sprite
// sombre virerait au gris sous la lampe.
@@ -1273,6 +2292,12 @@ float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float sh
// La matière d'un voxel se lit juste sous la surface : son flanc lit sa propre cellule.
float3 p = sculpt_point(q) - float3(n.x, -n.y, n.z) * 0.01;
m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2);
+ if (id == 3 || id == 4)
+ {
+ // Le serti de Prism Glow, comme un sprite : son dessus plat, face à la lampe, prendrait
+ // tout son reflet et virerait au gris.
+ gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z));
+ }
}
else
{
@@ -1298,6 +2323,177 @@ float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float sh
return model_tonemap(col);
}
+// ---- Le cristal de Prism Glow ----
+
+float prism_fresnel(float c, float ior)
+{
+ float f0 = (ior - 1.0) / (ior + 1.0);
+ f0 *= f0;
+ return f0 + (1.0 - f0) * pow(1.0 - c, 5.0);
+}
+
+// Le cristal sur le rayon (ro, rd) : distance au point touché (1e9 si raté ou hors de Prism Glow),
+// facette dans `hc`. Une paroi sous le dessus du serti ne compte pas : le rayon a traversé le
+// serti avant, c'est lui qu'on voit.
+float prism_primary(float3 ro, float3 rd, out int hc)
+{
+ hc = -1;
+ int shape = prism_shape();
+ if (shape < 0)
+ {
+ return 1e9;
+ }
+ float t = prism_trace(ro, rd, shape, hc);
+ if (hc >= 0 && fmod(PRISM_TRIS[4 * hc].w, 2.0) > 0.5 && ro.z + rd.z * t < 0.0)
+ {
+ hc = -1;
+ return 1e9;
+ }
+ return t;
+}
+
+// La normale de la facette `h`, tournée vers le rayon qui arrive le long de `rd`.
+float3 prism_normal(int h, float3 rd)
+{
+ float3 n = PRISM_TRIS[4 * h + 3].xyz;
+ return dot(n, rd) > 0.0 ? -n : n;
+}
+
+// Distance de `p` au segment [a, b].
+float prism_seg(float3 p, float3 a, float3 b)
+{
+ float3 pa = p - a, ba = b - a;
+ return length(pa - ba * saturate(dot(pa, ba) / dot(ba, ba)));
+}
+
+// Distance, en pixels (`px` = unités du modèle par pixel au point), de `p` aux arêtes réelles de
+// la facette `h` : les plis de l'art et le bord du serti.
+float prism_edge_px(float3 p, int h, float px)
+{
+ float4 r0 = PRISM_TRIS[4 * h];
+ float3 a = r0.xyz;
+ float3 b = a + PRISM_TRIS[4 * h + 1].xyz;
+ float3 c = a + PRISM_TRIS[4 * h + 2].xyz;
+ int bits = (int)(r0.w * 0.5);
+ float d = 1e9;
+ if ((bits & 1) != 0) d = min(d, prism_seg(p, b, c));
+ if ((bits & 2) != 0) d = min(d, prism_seg(p, c, a));
+ if ((bits & 4) != 0) d = min(d, prism_seg(p, a, b));
+ return d / px;
+}
+
+// La rotation inverse de `world_to_plane` : du repère du plan à celui de la caméra.
+float3 plane_to_world(float3 v, ModelFrame f)
+{
+ float x = v.x * f.c.z - v.y * f.s.z;
+ float y = v.x * f.s.z + v.y * f.c.z;
+ float z = -x * f.s.y + v.z * f.c.y;
+ return float3(x * f.c.y + v.z * f.s.y, y * f.c.x - z * f.s.x, y * f.s.x + z * f.c.x);
+}
+
+// Ce que montre l'image là où le rayon (q, d) du modèle retombe sur le plan : la copie de l'image
+// composée faite juste avant le curseur (t5), flous de confidentialité compris, lue au pixel de
+// sortie de ce point, en linéaire. Seuls des pixels déjà floutés passent donc à travers le verre.
+// L'œil est en (-mb.zw, persp) du repère caméra, le rayon du pixel `local` y va vers
+// (local + src.xy, -persp) : on remonte de ce point du plan à son `local`, puis à son uv de sortie.
+// Hors de l'image, l'écran sombre du studio.
+float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f)
+{
+ float denom = dot(d, nz);
+ if (denom > -1e-4)
+ {
+ return SCULPT_SCREEN * 0.2;
+ }
+ float3 g = q + d * ((hz - dot(q, nz)) / denom);
+ float3 w = plane_to_world(tip + unit * model_to_plane(g, f), f);
+ float k = 1.0 - w.z / src.z;
+ if (k < 1e-3)
+ {
+ return SCULPT_SCREEN * 0.2;
+ }
+ float2 uv = dst.xy + ((w.xy + mb.zw) / k - src.xy) / quad_px * dst.zw;
+ if (any(uv < 0.0) || any(uv > 1.0))
+ {
+ return SCULPT_SCREEN * 0.2;
+ }
+ return pow(texDof.SampleLevel(samp, uv, 0.0).rgb, 2.2);
+}
+
+// La couleur (sRGB, comme `model_shade`) du cristal au point `p` de la facette `h`, vu le long de
+// `rd` : reflet du studio, l'enregistrement réfracté par canal (la dispersion) à travers les
+// facettes et le fond plat, la lueur de la couleur de la facette dans l'art, ses plis en liseré.
+float3 prism_shade(float3 p, float3 n, float3 rd, int h, float px, float3 l, float3 fill, float3 nz,
+ float hz, float3 tip, float unit, ModelFrame f)
+{
+ int shape = prism_shape();
+ float4 r3 = PRISM_TRIS[4 * h + 3];
+ float3 facet = float3(PRISM_TRIS[4 * h + 1].w, PRISM_TRIS[4 * h + 2].w, r3.w);
+ float F = prism_fresnel(saturate(-dot(rd, n)), PRISM_IOR);
+ float zb = -model_thick();
+ float3 trans = 0.0;
+ [loop] for (int ch = 0; ch < 3; ch++)
+ {
+ // Le rouge plie le moins, le bleu le plus. FXC n'indexe pas un vecteur par une variable en
+ // écriture (il déroulerait la boucle) : le canal passe par un masque.
+ float3 mask = float3(ch == 0, ch == 1, ch == 2);
+ float ior = PRISM_IOR + PRISM_DISPERSION * (ch - 1);
+ float3 d = refract(rd, n, 1.0 / ior);
+ float3 pos = p;
+ float thr = 1.0;
+ float acc = 0.0;
+ [loop] for (int b = 0; b < PRISM_BOUNCES; b++)
+ {
+ int hh;
+ float th = prism_trace(pos, d, shape, hh);
+ float tb = d.z < -1e-6 ? (zb - pos.z) / d.z : 1e9;
+ if (tb <= th)
+ {
+ // Le fond plat : le rayon sort vers l'écran, ou s'y réfléchit tout entier.
+ float3 qb = pos + d * tb;
+ float3 d2 = refract(d, float3(0.0, 0.0, 1.0), ior);
+ if (dot(d2, d2) < 1e-8)
+ {
+ pos = qb;
+ d = reflect(d, float3(0.0, 0.0, 1.0));
+ continue;
+ }
+ acc += thr * dot(prism_screen(qb, d2, nz, hz, tip, unit, f), mask);
+ thr = 0.0;
+ break;
+ }
+ if (hh < 0)
+ {
+ break;
+ }
+ pos += d * th;
+ float4 rn = PRISM_TRIS[4 * hh + 3];
+ if (fmod(PRISM_TRIS[4 * hh].w, 2.0) > 0.5 && pos.z < 0.0)
+ {
+ // Une paroi sous le dessus du serti : le marine de son flanc intérieur.
+ acc += thr * dot(s_lin(0.03, 0.06, 0.37) * 0.35, mask);
+ thr = 0.0;
+ break;
+ }
+ float3 dout = refract(d, -rn.xyz, ior);
+ if (dot(dout, dout) < 1e-8)
+ {
+ d = reflect(d, -rn.xyz);
+ continue;
+ }
+ float fi = prism_fresnel(saturate(dot(dout, rn.xyz)), ior);
+ acc += thr * (1.0 - fi) * dot(model_env(dout, 0.05, l, fill), mask);
+ thr *= fi;
+ d = reflect(d, -rn.xyz);
+ }
+ trans += acc * mask;
+ }
+ float3 col = F * model_env(reflect(rd, n), 0.05, l, fill) + (1.0 - F) * trans + facet * PRISM_GLOW;
+ // Les plis de l'art : un liseré clair sur chaque arête réelle, fin d'un pixel.
+ float fold = 1.0 - smoothstep(0.2, 1.0, prism_edge_px(p, h, px));
+ col = lerp(col, s_lin(0.96, 0.99, 1.0) * 1.3, PRISM_FOLD * fold * (0.35 + 0.65 * saturate(dot(n, l))));
+ return model_tonemap(col);
+}
+
// Les passes de la boucle unique de `cursor_model`, dans l'ordre. Chaque tour n'évalue le modèle
// QU'UNE fois (`model_eval`) : FXC recopie une fonction à chaque appel, et un curseur sculpté est
// assez gros pour qu'un appel par passe multiplie par neuf le temps de compilation de ps_main.
@@ -1306,7 +2502,12 @@ static const int STAGE_NORMAL = 1; // quatre sondes en tétraèdre autour du poi
static const int STAGE_AO = 2; // cinq sondes le long de la normale, jusqu'à 0,08 du prototype
static const int STAGE_SELF = 3; // pénombre du modèle sur lui-même, vers la lampe (32 pas)
static const int STAGE_PLANE = 4; // le point du plan : son contact, puis sa pénombre (32 pas)
-static const int STAGE_DONE = 5;
+static const int STAGE_EDGE = 5; // quatre sondes à un demi-pixel du point touché : un bord ?
+static const int STAGE_SHADE = 6; // l'ombrage d'un échantillon, puis le rayon suivant s'il en reste
+static const int STAGE_DONE = 7;
+
+// Les trois rayons de plus d'un pixel au bord, en pixels autour de son centre.
+static const float2 MODEL_SUBPIXEL[3] = { float2(0.35, 0.2), float2(-0.35, 0.2), float2(0.0, -0.4) };
// Sonde `k` de la normale, sommet d'un tétraèdre.
float3 model_tetra(int k)
@@ -1347,17 +2548,30 @@ float4 cursor_model(float2 local)
float3 lamp = float3(color.rg + sprite_size() * 0.5, 0.0) + l * SCULPT_LAMP_DIST;
// Silhouette antialiasée : un rayon qui frôle le modèle à moins d'un pixel le couvre en partie
- // (`best`, la plus petite distance rencontrée, en pixels).
- float2 tb = ray_box(ro, rd, lo - 0.02, hi + 0.02);
+ // (`best`, la plus petite distance rencontrée, en pixels). Les bords intérieurs (deux matières,
+ // une arête, un arrondi serré) sont suréchantillonnés : `STAGE_EDGE` sonde le modèle à un
+ // demi-pixel du point touché, dans son plan tangent ; si la matière change ou que la surface
+ // s'écarte, trois rayons de plus (`MODEL_SUBPIXEL`) sont marchés et ombrés, avec l'occlusion et
+ // les ombres du premier. Chaque échantillon passe par `STAGE_SHADE`, seul appel de
+ // `model_shade` ; le pixel est leur moyenne.
+ float3 ray = rd;
+ float2 tb = ray_box(ro, ray, lo - 0.02, hi + 0.02);
int stage = tb.x < tb.y && tb.y > 0.0 ? STAGE_MARCH : STAGE_DONE;
+ // Prism Glow : le cristal est tracé au départ de chaque rayon ; `tc` est
+ // son point touché (1e9 : raté), `hc` sa facette. La marche du serti s'arrête à lui.
+ bool need_tc = stage == STAGE_MARCH;
+ float tc = 1e9;
+ int hc = -1;
// Le point du plan derrière le pixel est à régler avant le prochain tour.
bool plane_next = stage == STAGE_DONE;
int k = 0;
float t = max(tb.x, 0.0);
float best = 1e9;
float t_best = t;
+ float d_best = 0.0;
float mat = 0.0;
float cov = 0.0;
+ float cov_s = 0.0;
float3 q = 0.0;
float3 n = 0.0;
float3 L = 0.0;
@@ -1371,14 +2585,24 @@ float4 cursor_model(float2 local)
float inside = 0.0;
float contact = 0.0;
float dropped = 0.0;
- [loop] for (int it = 0; it < 180; it++)
+ // Sous-échantillons restants, échantillons ombrés, et leur somme (couleur × couverture,
+ // couverture).
+ int sub = 0;
+ int shaded = 0;
+ float4 acc = 0.0;
+ [loop] for (int it = 0; it < 500; it++)
{
+ if (need_tc)
+ {
+ need_tc = false;
+ tc = prism_primary(ro, ray, hc);
+ }
if (plane_next)
{
// Le plan, là où le modèle ne couvre pas tout le pixel : ombre portée et ombre de
// contact, seulement à l'intérieur de l'écran (`mb.xy` = sa demi-taille, px du plan).
plane_next = false;
- stage = STAGE_DONE;
+ stage = cov > 0.0 ? STAGE_SHADE : STAGE_DONE;
float denom = dot(rd, nz);
if (cov < 1.0 && denom < -1e-4)
{
@@ -1393,15 +2617,63 @@ float4 cursor_model(float2 local)
}
}
}
+ if (stage == STAGE_SHADE)
+ {
+ if (cov_s > 0.0)
+ {
+ float3 c;
+ if (mat > PRISM_MAT - 0.5)
+ {
+ c = prism_shade(q, n, ray, hc, t_best / dlen, l, fill, nz, hz, tip, unit, f);
+ }
+ else
+ {
+ float3 tl = lamp - q;
+ float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl);
+ c = model_shade(q, n, ray, normalize(tl), fall, sh, ao, mat, l, fill);
+ }
+ acc += float4(c * cov_s, cov_s);
+ }
+ shaded++;
+ stage = STAGE_DONE;
+ if (sub > 0)
+ {
+ // Le rayon suivant, décalé dans le pixel ; il repart de sa propre entrée dans la
+ // boîte, pour ne pas manquer ce qui passerait devant le premier.
+ float3 dws = float3(local + MODEL_SUBPIXEL[3 - sub] + src.xy, -persp);
+ sub--;
+ dlen = length(dws);
+ ray = plane_to_model(world_to_plane(dws / dlen, f), f);
+ tb = ray_box(ro, ray, lo - 0.02, hi + 0.02);
+ cov_s = 0.0;
+ stage = STAGE_SHADE;
+ if (tb.x < tb.y && tb.y > 0.0)
+ {
+ stage = STAGE_MARCH;
+ k = 0;
+ t = max(tb.x, 0.0);
+ best = 1e9;
+ t_best = t;
+ need_tc = true;
+ }
+ }
+ continue;
+ }
float3 pos;
if (stage == STAGE_MARCH)
{
- pos = ro + rd * t;
+ pos = ro + ray * t;
}
else if (stage == STAGE_NORMAL)
{
pos = q + 0.002 * model_tetra(k);
}
+ else if (stage == STAGE_EDGE)
+ {
+ float3 side = normalize(cross(n, ray));
+ float3 e = (k >= 2 ? cross(n, side) : side) * (k % 2 == 1 ? -1.0 : 1.0);
+ pos = q + e * 0.5 * t_best / dlen;
+ }
else if (stage == STAGE_AO)
{
pos = q + (0.01 + 0.0175 * k) * SCULPT_SCALE * n;
@@ -1428,22 +2700,68 @@ float4 cursor_model(float2 local)
{
best = hit ? 0.0 : d / fp;
t_best = t;
+ d_best = d;
mat = m.y;
}
t += d * stride;
k++;
+ if (!hit && t >= tc)
+ {
+ // Le pas dépasse le cristal : c'est lui que le rayon touche, avant le serti.
+ hit = true;
+ best = 0.0;
+ t_best = tc;
+ d_best = 0.0;
+ mat = PRISM_MAT;
+ }
if (hit || t > tb.y || k == 96)
{
- cov = saturate(1.0 - best);
- if (cov > 0.0)
+ cov_s = saturate(1.0 - best);
+ if (shaded > 0)
{
- q = ro + rd * t_best;
- stage = STAGE_NORMAL;
- k = 0;
+ stage = STAGE_SHADE;
+ if (cov_s > 0.0)
+ {
+ q = ro + ray * t_best;
+ n = 0.0;
+ stage = STAGE_NORMAL;
+ k = 0;
+ }
}
else
{
- plane_next = true;
+ cov = cov_s;
+ if (cov > 0.0)
+ {
+ q = ro + ray * t_best;
+ stage = STAGE_NORMAL;
+ k = 0;
+ }
+ else
+ {
+ plane_next = true;
+ }
+ }
+ if (stage == STAGE_NORMAL && mat > PRISM_MAT - 0.5)
+ {
+ // Le cristal : la normale de sa facette, sans sondes ; ni occlusion ni ombre
+ // propre. Un bord réel à moins d'un pixel (le serti, un pli) appelle les trois
+ // rayons de plus, comme les bords intérieurs des autres modèles.
+ n = prism_normal(hc, ray);
+ k = 0;
+ if (shaded > 0)
+ {
+ stage = STAGE_SHADE;
+ }
+ else
+ {
+ if (prism_edge_px(q, hc, t_best / dlen) < 0.75)
+ {
+ sub = 3;
+ }
+ stage = STAGE_DONE;
+ plane_next = true;
+ }
}
}
}
@@ -1454,6 +2772,34 @@ float4 cursor_model(float2 local)
if (k == 4)
{
n = normalize(n);
+ k = 0;
+ stage = shaded > 0 ? STAGE_SHADE : STAGE_EDGE;
+ }
+ }
+ else if (stage == STAGE_EDGE)
+ {
+ // Autre matière, ou surface qui s'écarte du plan tangent de plus de 2 % d'un pixel
+ // (le point touché flotte déjà de `d_best` au-dessus d'elle).
+ if (m.y != mat || abs(d - d_best) > 0.02 * t_best / dlen)
+ {
+ sub = 3;
+ }
+ if (k == 0 && mat > 7.5)
+ {
+ // Le serti de Prism Glow au bord du cristal : son contour à moins d'un pixel.
+ int ps = prism_shape();
+ if (ps >= 0)
+ {
+ float dout = prism_poly_dist(q.xy, PRISM_OUT_START[ps], PRISM_OUT_COUNT[ps]);
+ if (abs(dout) < 0.75 * t_best / dlen)
+ {
+ sub = 3;
+ }
+ }
+ }
+ k++;
+ if (k == 4)
+ {
stage = STAGE_AO;
k = 0;
ao = 0.0;
@@ -1504,7 +2850,7 @@ float4 cursor_model(float2 local)
k = 1;
if (!(tbp.x < tbp.y && tbp.y > 0.0))
{
- stage = STAGE_DONE;
+ stage = cov > 0.0 ? STAGE_SHADE : STAGE_DONE;
}
}
else
@@ -1516,21 +2862,15 @@ float4 cursor_model(float2 local)
{
res = saturate(res);
dropped = 1.0 - res * res * (3.0 - 2.0 * res);
- stage = STAGE_DONE;
+ stage = cov > 0.0 ? STAGE_SHADE : STAGE_DONE;
}
}
}
- float3 rgb = 0.0;
- if (cov > 0.0)
- {
- float3 tl = lamp - q;
- float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl);
- rgb = model_shade(q, n, rd, normalize(tl), fall, sh, ao, mat, l, fill);
- }
+ float w = 1.0 / max(shaded, 1);
float shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA);
- float a = cov * color.a;
- return float4(rgb * a, a + (1.0 - a) * shadow * color.a); // prémultiplié, ombre noire
+ float a = acc.a * w * color.a;
+ return float4(acc.rgb * w * color.a, a + (1.0 - a) * shadow * color.a); // prémultiplié, ombre noire
}
// ============ Impact du clic (mode 16) ============
@@ -2154,7 +3494,8 @@ float4 device_frame(float2 local)
return float4(rgb * a, a); // prémultiplié
}
-float4 ps_main(VSOut i) : SV_Target
+// The body of `ps_main`, which only adds the layer's transparency on top of it.
+float4 ps_layer(VSOut i)
{
// mode 18 : l'écran CADRÉ (ombre, cadre, métrage, appareil) flouté comme UN objet rigide
// (`FrameGeometry::screen_trail`). t2 = son rendu isolé, prémultiplié, à la taille de la
@@ -2213,6 +3554,7 @@ float4 ps_main(VSOut i) : SV_Target
return acc / max(n, 1.0);
}
+#ifdef LAYER_MODELS
// mode 17 : CADRE D'APPAREIL MODELÉ (cf. `device_frame`). Testé en premier, comme le 16.
if (mode > 16.5)
{
@@ -2236,6 +3578,14 @@ float4 ps_main(VSOut i) : SV_Target
}
return cursor_model(i.local);
}
+#else
+ // Les modèles 3D n'existent que dans `ps_main_models` ; un draw qui les enverrait ici ne
+ // peint rien plutôt qu'une ombre faite de leurs emplacements.
+ if (mode > 14.5)
+ {
+ return float4(0.0, 0.0, 0.0, 0.0);
+ }
+#endif
// mode 14 : CADRE DE FENÊTRE autour de l'écran (barre de titre, trois pastilles, filet),
// dessiné SOUS lui. Testé avant le mode 13, dont la branche n'a pas de borne haute.
@@ -2490,8 +3840,12 @@ float4 ps_main(VSOut i) : SV_Target
float2 plane_px = dst_prev.xy;
float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5;
float rad = max(radius_px, 0.0);
- float d = (dst_prev.z > 0.5) ? sd_screen_under_bar(p, plane_px * 0.5, rad, color.z)
- : sd_round_rect(p, plane_px * 0.5, rad);
+ // Une branche et non `?:`, qui évalue ses DEUX opérandes (trois SDF par pixel).
+ float d;
+ if (dst_prev.z > 0.5)
+ d = sd_screen_under_bar(p, plane_px * 0.5, rad, color.z);
+ else
+ d = sd_round_rect(p, plane_px * 0.5, rad);
float tilt_a = 1.0 - smoothstep(0.0, 1.5, d);
// Slot d'un layout en bloc (`color.w` = rayon de ses coins, px ; 0 ailleurs, sans effet) :
// `dst` EST le slot, dont le rect arrondi rogne le plan. Le conteneur masque, le métrage
@@ -2653,6 +4007,8 @@ float4 ps_main(VSOut i) : SV_Target
float3 rgb;
// 1 sauf en mode detourage, ou il porte le masque du sujet (cf. la branche fx.z ci-dessous).
float alpha_mask = 1.0;
+ // 0 where a camera homography finds no camera point (`persp_camera`), 1 everywhere else.
+ float persp_keep = 1.0;
if (mode < 0.5)
{
// flou de mouvement par vélocité (§8) : pour CE pixel sortie, uv à la frame
@@ -2661,6 +4017,17 @@ float4 ps_main(VSOut i) : SV_Target
float2 uv_now = i.uv;
float2 localp = (i.pout - dst_prev.xy) / dst_prev.zw;
float2 uv_prev = src_prev.xy + localp * (src_prev.zw - src_prev.xy);
+ if (layer_fx.y > 0.5)
+ {
+ // Camera homography: the texture uv comes from the fragment's place in the quad
+ // (`src` is not used), scaled to the valid part of the decoder texture. The previous
+ // frame's uv is the same map at the quad's previous place.
+ float3 cam = persp_camera((i.pout - dst.xy) / dst.zw);
+ persp_keep = cam.z;
+ uv_now = cam.xy * fx.xy;
+ float3 cam_prev = persp_camera(localp);
+ uv_prev = (cam_prev.z > 0.5) ? cam_prev.xy * fx.xy : uv_now;
+ }
float2 duv = uv_now - uv_prev;
float mb_scale = saturate(mb.y);
float2 duv_blur = duv * mb_scale;
@@ -2701,20 +4068,28 @@ float4 ps_main(VSOut i) : SV_Target
}
else if (effect > 1.5)
{
- rgb = lerp(blur_webcam_bg(uv_now, fx.w, quad_px, i.local), rgb, person);
+ rgb = lerp(blur_webcam_bg(uv_now, fx.w, quad_px, i.local, fx.xy), rgb, person);
}
else
{
alpha_mask = person;
}
}
+
+ // Desk-view cover: the camera is being tilted, so the whole picture is blurred and
+ // dimmed (cover.x = strength, cover.y = radius in quad px, cover.z = dim at full cover).
+ if (cover.x > 0.001)
+ {
+ float3 hidden = blur_webcam_radius(uv_now, cover.y, quad_px, i.local, fx.xy);
+ rgb = lerp(rgb, hidden, cover.x) * (1.0 - cover.z * cover.x);
+ }
}
else
{
rgb = color.rgb;
}
- float alpha = color.a * alpha_mask;
+ float alpha = color.a * alpha_mask * persp_keep;
if (radius_px > 0.0)
{
// `quad_px` est en px de SORTIE (le render target porte la géométrie de sortie) et
@@ -2726,13 +4101,24 @@ float4 ps_main(VSOut i) : SV_Target
// cadre (`sd_screen_under_bar`, `mb.z` = la remontée du contour intérieur, px).
float2 halfsz = quad_px * 0.5;
float2 p = i.local - quad_px * 0.5;
- float d = (mb.w > 0.5) ? sd_screen_under_bar(p, halfsz, radius_px, mb.z)
- : sd_round_rect(p, halfsz, radius_px);
+ // Une branche et non `?:`, qui évalue ses DEUX opérandes : trois SDF par pixel de l'écran
+ // au lieu d'une -- 0,6 ms par frame 1080p sur une Radeon 610M (mesuré en WGSL).
+ float d;
+ if (mb.w > 0.5)
+ d = sd_screen_under_bar(p, halfsz, radius_px, mb.z);
+ else
+ d = sd_round_rect(p, halfsz, radius_px);
alpha *= 1.0 - smoothstep(0.0, 1.5, d); // ~1.5px feather (§7 fwidth-like)
}
return float4(rgb * alpha, alpha); // prémultiplié
}
+float4 ps_main(VSOut i) : SV_Target
+{
+ // Premultiplied, so the transparency scales all four channels; 0 leaves them as they are.
+ return ps_layer(i) * (1.0 - layer_fx.x);
+}
+
// ============ RGB -> NV12 (§5) : deux passes vers les plans d'une texture NV12 ============
// VS plein écran (triangle unique) qui expose l'UV.
struct FSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal
index 6c019c3ec..b0e2cb40e 100644
--- a/crates/compositor/src/shaders.metal
+++ b/crates/compositor/src/shaders.metal
@@ -33,6 +33,12 @@
// `compositor_macos::tests::every_shader_entry_point_compiles` le compile sur le device
// système au `cargo test`, pour qu'une faute de syntaxe MSL ne se découvre pas à
// l'ouverture de l'éditeur chez un utilisateur.
+//
+// Il est compilé DEUX fois (`compositor_macos.rs::new_sized`) : tel quel pour tous les calques,
+// et avec `LAYER_MODELS` défini pour les seuls modèles 3D (modes 15 à 17,
+// `LayerCB::needs_models`). Un shader alloue à chaque draw les registres de sa branche la plus
+// lourde, et ce sont ces modèles ray-tracés : le même `ps_main` les portait pour le fond,
+// l'ombre et la vidéo, qui couvrent toute la sortie (cf. le haut de `shaders.hlsl`).
#include
using namespace metal;
@@ -42,7 +48,7 @@ using namespace metal;
// =================================================================================
//
// Le moteur côté CPU upload ce buffer via `setVertexBytes` (vertex stage) et
-// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 176 octets,
+// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 256 octets,
// ce qui est sous le seuil d'alignement 4K de Metal pour le mode « immediate ».
struct Layer
@@ -60,6 +66,9 @@ struct Layer
float4 trail_a; // mode 8 : coins TL, TR du plan à la frame précédente (px locaux, comme fx) ; mode 18 incliné : en fractions de sortie
float4 trail_b; // mode 8 : coins BR, BL du plan à la frame précédente (comme src_prev) ; mode 18 incliné : en fractions de sortie
float4 trail_mb; // mode 8 : x = taps, y = force du flou de mouvement (ceux du mode 0) ; 0 ailleurs
+ float4 cover; // x = desk-view cover 0..1, y = blur radius (quad px), z = dim, w unused
+ float4 persp[3]; // mode 0: rows of the homography quad point (0..1 in dst) -> camera uv (0..1 of the valid frame), xyz; read when layer_fx.y = 1
+ float4 layer_fx; // x = transparency 0..1 (0 = opaque), every mode; y = 1 when persp applies; z, w unused
};
// Mode 15 (curseur modélisé) : le détail des emplacements est dans `frame_geometry.rs`, en tête
// de la section « Curseur modélisé » (`cursor_model_cb`). Mode 17 (appareil modelé) : en tête de
@@ -170,9 +179,13 @@ inline float sd_segment(float2 p, float2 a, float2 b)
inline float sd_round_rect(float2 p, float2 halfsz, float r)
{
float hmin = min(halfsz.x, halfsz.y);
- if (r <= 0.0 || hmin <= 0.0)
+ float2 q0 = abs(p) - halfsz;
+ // Hors des coins, la distance est celle du rectangle vif quel que soit l'exposant : un seul
+ // axe déborde de l'étendue E, qui s'y ajoute puis s'en retranche. E ne dépasse pas 1,42 r
+ // (n = 3) : sous 1,5 r du bord sur un axe, on rend la boîte sans calculer `n` ni E, deux
+ // transcendantes de moins sur presque tous les pixels d'un calque arrondi.
+ if (r <= 0.0 || hmin <= 0.0 || min(q0.x, q0.y) <= -min(1.5 * r, hmin))
{
- float2 q0 = abs(p) - halfsz;
return length(max(q0, 0.0)) + min(max(q0.x, q0.y), 0.0);
}
float n = 3.0 - smoothstep(0.5, 1.0, r / hmin);
@@ -414,12 +427,17 @@ constant float3 VOGEL_TAPS[21] = {
float3(-0.633036, -0.758588, 0.087119)
};
-inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px,
- texture2d texY,
- texture2d texUV)
+// Vogel-disc blur of the camera texture at a radius in quad pixels. `valid` is the part of the
+// texture the picture fills (fx.xy): decoders allocate aligned textures (a 1080-line camera in a
+// 1088-line texture), so each tap is clamped half a chroma texel inside it, never into padding.
+inline float3 blur_webcam_radius(float2 uv, float max_r_px, float2 qpx, float2 local_px,
+ float2 valid,
+ texture2d texY,
+ texture2d texUV)
{
- float max_r_px = max(intensity, 0.0) * 22.0 + 1.5;
float2 step = max_r_px / max(qpx, float2(1.0));
+ float2 chroma = max(float2(float(texUV.get_width()), float(texUV.get_height())), float2(1.0));
+ float2 hi = max(valid - 0.5 / chroma, float2(0.0));
float noise = fract(52.9829189 * fract(0.06711056 * local_px.x + 0.00583715 * local_px.y));
float angle = noise * 6.2831853;
float s = sin(angle);
@@ -431,12 +449,34 @@ inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 loca
float2 p = VOGEL_TAPS[k].xy;
float w = VOGEL_TAPS[k].z;
float2 rot_p = float2(p.x * c - p.y * s, p.x * s + p.y * c);
- sum += sample_yuv(saturate(uv + rot_p * step), texY, texUV) * w;
+ sum += sample_yuv(clamp(uv + rot_p * step, float2(0.0), hi), texY, texUV) * w;
total += w;
}
return sum / max(total, 1e-4);
}
+// Camera homography (`persp`, read when `layer_fx.y` = 1): the camera point (0..1 of its valid
+// frame) seen at `local` (0..1 in the layer's dst quad), and in z whether there is one -- 0 when
+// the point is behind the projection (q.z <= 0) or outside the camera frame, where the layer is
+// transparent. Mirror of the HLSL `persp_camera`.
+inline float3 persp_camera(constant Layer &layer, float2 local)
+{
+ float3 p = float3(local, 1.0);
+ float3 q = float3(dot(layer.persp[0].xyz, p), dot(layer.persp[1].xyz, p), dot(layer.persp[2].xyz, p));
+ float2 cam = q.xy / max(q.z, 1e-6);
+ bool inside = q.z > 0.0 && all(cam >= 0.0) && all(cam <= 1.0);
+ return float3(cam, inside ? 1.0 : 0.0);
+}
+
+inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px,
+ float2 valid,
+ texture2d texY,
+ texture2d texUV)
+{
+ return blur_webcam_radius(uv, max(intensity, 0.0) * 22.0 + 1.5, qpx, local_px, valid, texY,
+ texUV);
+}
+
// Hash 2D -> [0,1) sans sin(). Miroir de `hash12` côté HLSL.
inline float hash12(float2 p)
{
@@ -545,7 +585,9 @@ inline float3 image_motion(float2 q, float time, float motion, float aspect)
// ============ Curseur MODÉLISÉ (mode 15) ============
// Port ligne pour ligne de `cursor_model` (HLSL), dont les commentaires font foi ; seules
// différences : `layer` et les textures arrivent en paramètres (le sprite en texture(2), son
-// champ R16F en texture(4)), `lerp` s'écrit `mix`, `SampleLevel` s'écrit `sample(…, level(0.0))`,
+// champ R16F en texture(4), la copie de l'image composée en texture(5) pour le cristal de Prism
+// Glow),
+// `lerp` s'écrit `mix`, `SampleLevel` s'écrit `sample(…, level(0.0))`, un `out` s'écrit `thread &`,
// et `pow` veut un exposant du type de sa base.
// Constantes : miroir exact de `frame_geometry.rs` (MODEL_*) et de `sculpt.rs` (SCULPT_*).
constant float MODEL_BEVEL = 0.045;
@@ -590,8 +632,6 @@ inline int sculpt_id(constant Layer &layer)
constant float SCULPT_SCALE = 0.85;
constant float SCULPT_HOVER = 0.05;
constant float SCULPT_VOX = 0.0625;
-constant float SCULPT_AR_ROUND = 0.03;
-constant float SCULPT_HAND_ZC = 0.185;
constant float SCULPT_ZREF_ARROW = 0.2;
constant float SCULPT_ZREF_HAND = 0.185;
constant float SCULPT_LAMP_DIST = 1.9;
@@ -613,13 +653,6 @@ inline float2 s_opu(float2 a, float2 b)
return a.x < b.x ? a : b;
}
-static float s_capsule(float3 p, float3 a, float3 b, float r)
-{
- float3 pa = p - a, ba = b - a;
- float h = saturate(dot(pa, ba) / dot(ba, ba));
- return length(pa - ba * h) - r;
-}
-
static float s_round_box(float3 p, float3 b, float r)
{
float3 q = abs(p) - b + r;
@@ -646,24 +679,76 @@ inline float2 s_rot(float2 v, float a)
return float2(c * v.x - s * v.y, s * v.x + c * v.y);
}
-constant float2 SCULPT_ARROW[7] = {
- float2(0.0, 0.0), float2(0.0, -0.86), float2(0.215, -0.665), float2(0.37, -1.0),
- float2(0.53, -0.93), float2(0.38, -0.60), float2(0.64, -0.60)
+static float s_star5(float2 p, float r, float rf)
+{
+ const float2 k1 = float2(0.809016994375, -0.587785252292);
+ const float2 k2 = float2(-0.809016994375, -0.587785252292);
+ p.x = abs(p.x);
+ p -= 2.0 * max(dot(k1, p), 0.0) * k1;
+ p -= 2.0 * max(dot(k2, p), 0.0) * k2;
+ p.x = abs(p.x);
+ p.y -= r;
+ float2 ba = rf * float2(-k1.y, k1.x) - float2(0.0, 1.0);
+ float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
+ return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
+}
+
+static float s_vesica(float2 p, float r, float d)
+{
+ p = abs(p);
+ float b = sqrt(r * r - d * d);
+ return (p.y - b) * d > p.x * b ? length(p - float2(0.0, b)) : length(p + float2(d, 0.0)) - r;
+}
+
+// Curseurs cercles (cf. HLSL) : Studio Ink, Pop Coral, Star Sprout.
+constant float2 RIM_POLY[28] = {
+ float2(0.0, -0.06), float2(0.0, -0.7712), float2(0.165, -0.6325), float2(0.313, -0.9318),
+ float2(0.4234, -0.8557), float2(0.2672, -0.5686), float2(0.456, -0.5393),
+ float2(-0.173, -0.5237), float2(-0.016, -0.75), float2(0.329, -0.75), float2(0.411, -0.592),
+ float2(0.411, -0.43), float2(0.19, -0.435), float2(-0.03, -0.44),
+ float2(-0.1825, -0.6215), float2(0.0037, -0.9195), float2(0.3462, -0.9195), float2(0.467, -0.6906),
+ float2(0.467, -0.49), float2(0.2, -0.49), float2(-0.03, -0.49),
+ float2(-0.1807, -0.5534), float2(0.0194, -0.872), float2(0.3485, -0.872), float2(0.47, -0.6795),
+ float2(0.47, -0.49), float2(0.2, -0.49), float2(-0.03, -0.49)
+};
+
+constant float4 RIM_GLOVE[27] = {
+ float4(0.0, -0.1077, 0.0, -0.6), float4(0.1493, -0.3279, 0.1493, -0.6),
+ float4(0.2863, -0.3654, 0.2863, -0.6), float4(0.4193, -0.4043, 0.4193, -0.6),
+ float4(-0.2, -0.478, -0.075, -0.625), float4(0.0788, -0.25, 0.0788, -0.3992),
+ float4(0.2188, -0.3, 0.2188, -0.4234), float4(0.3552, -0.33, 0.3552, -0.4534),
+ float4(-0.0845, -0.5386, 0.762, 0.648),
+ float4(0.0, -0.1186, 0.0, -0.65), float4(0.1614, -0.3838, 0.1614, -0.65),
+ float4(0.3177, -0.427, 0.3177, -0.65), float4(0.4645, -0.4848, 0.4645, -0.65),
+ float4(-0.215, -0.535, -0.1208, -0.6864), float4(0.0832, -0.3, 0.0832, -0.48),
+ float4(0.2414, -0.35, 0.2414, -0.482), float4(0.3959, -0.41, 0.3959, -0.533),
+ float4(-0.122, -0.5718, 0.867, 0.498),
+ float4(0.0, -0.122, 0.0, -0.65), float4(0.172, -0.387, 0.172, -0.65),
+ float4(0.3267, -0.427, 0.3267, -0.65), float4(0.4727, -0.4867, 0.4727, -0.65),
+ float4(-0.199, -0.483, -0.102, -0.645), float4(0.0927, -0.3, 0.0927, -0.476),
+ float4(0.2493, -0.35, 0.2493, -0.478), float4(0.402, -0.41, 0.402, -0.52),
+ float4(-0.0993, -0.5267, 0.858, 0.514)
+};
+
+constant float4 RIM_GLOVE_R[9] = {
+ float4(0.0552, 0.047, 0.045, 0.0375), float4(0.052, 0.0235, 0.0225, 0.0225), float4(0.0552, -0.548, 0.0525, 0.0),
+ float4(0.064, 0.0585, 0.0585, 0.0515), float4(0.06, 0.0197, 0.0178, 0.0172), float4(0.0648, -0.6, 0.058, 0.0),
+ float4(0.066, 0.0553, 0.0553, 0.0507), float4(0.063, 0.024, 0.022, 0.02), float4(0.0647, -0.555, 0.056, 0.0)
};
-static float s_arrow2(float2 p)
+static float s_rim_poly(float2 p, int base)
{
- float d = dot(p - SCULPT_ARROW[0], p - SCULPT_ARROW[0]);
+ float d = dot(p - RIM_POLY[base], p - RIM_POLY[base]);
float s = 1.0;
- int j = 6;
- for (int i = 0; i < 7; i++)
+ int j = base + 6;
+ for (int i = base; i < base + 7; i++)
{
- float2 e = SCULPT_ARROW[j] - SCULPT_ARROW[i];
- float2 w = p - SCULPT_ARROW[i];
+ float2 e = RIM_POLY[j] - RIM_POLY[i];
+ float2 w = p - RIM_POLY[i];
float2 b = w - e * saturate(dot(w, e) / dot(e, e));
d = min(d, dot(b, b));
- bool c0 = p.y >= SCULPT_ARROW[i].y;
- bool c1 = p.y < SCULPT_ARROW[j].y;
+ bool c0 = p.y >= RIM_POLY[i].y;
+ bool c1 = p.y < RIM_POLY[j].y;
bool c2 = e.x * w.y > e.y * w.x;
if ((c0 && c1 && c2) || (!c0 && !c1 && !c2))
{
@@ -674,276 +759,1190 @@ static float s_arrow2(float2 p)
return s * sqrt(d);
}
-static float s_star5(float2 p, float r, float rf)
+static float2 s_rim_glove(float2 p, int g, float palm)
{
- const float2 k1 = float2(0.809016994375, -0.587785252292);
- const float2 k2 = float2(-0.809016994375, -0.587785252292);
- p.x = abs(p.x);
- p -= 2.0 * max(dot(k1, p), 0.0) * k1;
- p -= 2.0 * max(dot(k2, p), 0.0) * k2;
- p.x = abs(p.x);
- p.y -= r;
- float2 ba = rf * float2(-k1.y, k1.x) - float2(0.0, 1.0);
- float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
- return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
+ float4 r0 = RIM_GLOVE_R[3 * g];
+ float4 r1 = RIM_GLOVE_R[3 * g + 1];
+ float4 r2 = RIM_GLOVE_R[3 * g + 2];
+ int i = 9 * g;
+ float f = min(sd_segment(p, RIM_GLOVE[i].xy, RIM_GLOVE[i].zw) - r0.x,
+ sd_segment(p, RIM_GLOVE[i + 1].xy, RIM_GLOVE[i + 1].zw) - r0.y);
+ f = min(f, sd_segment(p, RIM_GLOVE[i + 2].xy, RIM_GLOVE[i + 2].zw) - r0.z);
+ f = min(f, sd_segment(p, RIM_GLOVE[i + 3].xy, RIM_GLOVE[i + 3].zw) - r0.w);
+ f = min(f, sd_segment(p, RIM_GLOVE[i + 4].xy, RIM_GLOVE[i + 4].zw) - r1.x);
+ float grooves = min(sd_segment(p, RIM_GLOVE[i + 5].xy, RIM_GLOVE[i + 5].zw) - r1.y,
+ sd_segment(p, RIM_GLOVE[i + 6].xy, RIM_GLOVE[i + 6].zw) - r1.z);
+ grooves = min(grooves, sd_segment(p, RIM_GLOVE[i + 7].xy, RIM_GLOVE[i + 7].zw) - r1.w);
+ float sil = s_smin(palm, f, 0.03);
+ float4 v = RIM_GLOVE[i + 8];
+ float wedge = max(max(-dot(p - v.xy, v.zw), p.x + r2.x), r2.y - p.y);
+ return float2(sil, max(sil, -min(grooves, wedge)));
}
-static float s_arrow_solid(float3 p, float h, float re, float dome)
+static float2 s_piece(float d, float dc, float sil, float z, float w, float zt, float h, float bump, float mat)
{
- float d2 = s_arrow2(p.xy) - SCULPT_AR_ROUND;
- float hh = h + dome * smoothstep(0.0, 0.07, -d2);
- return s_extrude(d2, p.z - (SCULPT_HOVER + h + dome), hh, re);
+ float tray = s_extrude(sil - w, z - 0.5 * (SCULPT_HOVER + zt), 0.5 * (zt - SCULPT_HOVER), 0.012);
+ float bead = length(float2(d - 0.5 * w, z - zt)) - 0.5 * w;
+ float u = saturate(-dc / 0.045);
+ float cushion = 0.8 * s_extrude(dc, z - zt, h + 0.022 * u * (2.0 - u) + bump, 0.75 * h);
+ return s_opu(float2(min(tray, bead), 5.0), float2(cushion, mat));
}
-static float s_piping(float3 p, float ztop)
+static float2 s_paper(float d, float sil, float z, float w)
{
- float d2 = s_arrow2(p.xy) - SCULPT_AR_ROUND;
- return length(float2(d2 + 0.075, p.z - ztop)) - 0.017;
+ float navy = s_extrude(sil - w, z - 0.5 * (SCULPT_HOVER + 0.15), 0.5 * (0.15 - SCULPT_HOVER), 0.006);
+ float sheet = s_extrude(d, z - 0.16, 0.01, 0.004);
+ return s_opu(float2(navy, 5.0), float2(sheet, 1.0));
}
-static float s_glove(float3 p, float zc)
+inline float s_bump(float2 p, float2 c, float r)
{
- float index = s_capsule(p, float3(0.0, -0.10, zc), float3(0.0, -0.52, zc), 0.098);
- float palm = s_round_box(p - float3(0.185, -0.70, zc), float3(0.255, 0.19, 0.105), 0.1);
- float f1 = s_capsule(p, float3(0.17, -0.57, zc + 0.012), float3(0.17, -0.41, zc + 0.045), 0.086);
- float f2 = s_capsule(p, float3(0.31, -0.59, zc + 0.01), float3(0.31, -0.45, zc + 0.04), 0.08);
- float f3 = s_capsule(p, float3(0.435, -0.625, zc + 0.005), float3(0.435, -0.52, zc + 0.03), 0.07);
- float thumb = s_capsule(p, float3(0.03, -0.77, zc + 0.03), float3(-0.165, -0.60, zc + 0.065), 0.082);
- float d = s_smin(palm, min(f1, min(f2, f3)), 0.035);
- d = s_smin(d, index, 0.05);
- return s_smin(d, thumb, 0.05);
+ float k = saturate(1.0 - dot(p - c, p - c) / (r * r));
+ return k * k;
}
-static float s_cuff(float3 p, float zc)
+static float s_dash(float2 p, float2 a, float2 b, float ra, float rb)
{
- float3 q = p - float3(0.185, -0.925, zc);
- float2 ab = float2(0.272, 0.12);
- float e = (length(q.xz / ab) - 1.0) * min(ab.x, ab.y);
- return s_extrude(e, q.y, 0.07, 0.06);
+ p -= a;
+ b -= a;
+ float hb = dot(b, b);
+ float2 q = float2(abs(dot(p, float2(b.y, -b.x))), dot(p, b)) / hb;
+ float2 c = float2(sqrt(hb - (ra - rb) * (ra - rb)), ra - rb);
+ float k = c.x * q.y - c.y * q.x;
+ if (k < 0.0)
+ {
+ return sqrt(hb * dot(q, q)) - ra;
+ }
+ if (k > c.x)
+ {
+ return sqrt(hb * (dot(q, q) + 1.0 - 2.0 * q.y)) - rb;
+ }
+ return dot(c, q) - ra;
}
-static float2 s_sprout(float3 p, float3 c)
+static float2 s_rimmed(float3 p, int theme, int shape)
{
- float3 q = p - c;
- float st2 = s_star5(q.xy, 0.125, 0.52) - 0.022;
- float2 r = float2(s_extrude(st2, q.z, 0.038, 0.034), 3.0);
- float3 e = float3(abs(q.x) - 0.032, q.y + 0.005, q.z - 0.036);
- r = s_opu(r, float2(length(e) - 0.0135, 5.0));
- float3 l1 = float3(s_rot(q.xy - float2(-0.04, 0.15), -0.6), q.z);
- float3 l2 = float3(s_rot(q.xy - float2(0.045, 0.155), 0.7), q.z);
- float leaves = min(s_ellipsoid(l1, float3(0.03, 0.058, 0.02)), s_ellipsoid(l2, float3(0.03, 0.058, 0.02)));
- return s_opu(r, float2(leaves, 4.0));
-}
-
-// Pixel Candy : une ligne par entier, bit c = colonne c (cf. HLSL et `sculpt.rs`).
-constant int SCULPT_ARROWPIX[16] = { 0, 1, 3, 7, 31, 63, 127, 255, 511, 63, 55, 115, 113, 224, 224, 64 };
-constant int SCULPT_ARROWBACK[18] = {
- 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 255, 511, 511, 999, 995, 960, 192
+ bool arrow = shape == 0;
+ int g = theme == 4 ? 0 : (theme == 0 ? 1 : 2);
+ float poly = s_rim_poly(p.xy, arrow ? 0 : 7 * g + 7);
+ float2 body = float2(poly, poly);
+ float w = 0.06, zt = 0.17, h = 0.03;
+ float bump = 0.022 * s_bump(p.xy, float2(0.15, -0.45), 0.3);
+ float back = 1e9;
+ if (!arrow)
+ {
+ w = RIM_GLOVE_R[3 * g + 2].z;
+ for (int i = 0; i < 2; i++)
+ {
+ float2 q = p.xy + (i == 1 ? float2(0.03, 0.04) : float2(0.0, 0.0));
+ float2 v = s_rim_glove(q, g, (i == 1 ? s_rim_poly(q, 7 * g + 7) : poly) - 0.05);
+ if (i == 1)
+ {
+ back = v.x - w;
+ }
+ else
+ {
+ body = v;
+ if (theme != 2 || v.x - w <= 0.0)
+ {
+ break;
+ }
+ }
+ }
+ zt = 0.185;
+ h = 0.028;
+ bump = 0.03 * s_bump(p.xy, float2(0.19, -0.62), 0.28);
+ }
+ float dc = body.y;
+ if (arrow && theme == 0)
+ {
+ body -= 0.015;
+ w = 0.045;
+ dc = 1.0;
+ }
+ float2 r = theme == 2 ? s_paper(body.y, body.x, p.z, w)
+ : s_piece(body.y, dc, body.x, p.z, w, zt, h, bump, 1.0);
+ if (theme == 0)
+ {
+ if (arrow)
+ {
+ float band = s_extrude(abs(poly + 0.0075) - 0.0225, p.z - zt, 0.028, 0.012);
+ r = s_opu(r, float2(band, 2.0));
+ }
+ return r;
+ }
+ if (theme == 2)
+ {
+ float dash;
+ if (arrow)
+ {
+ float back = s_rim_poly(p.xy + float2(0.025, 0.05), 0) - 0.06;
+ r = s_opu(r, float2(s_extrude(back, p.z - 0.08, 0.03, 0.006), 2.0));
+ dash = min(s_dash(p.xy, float2(-0.1179, -0.1374), float2(-0.1799, -0.0443), 0.028, 0.045),
+ s_dash(p.xy, float2(-0.1347, -0.2493), float2(-0.248, -0.2056), 0.026, 0.042));
+ }
+ else
+ {
+ r = s_opu(r, float2(s_extrude(back, p.z - 0.08, 0.03, 0.006), 3.0));
+ dash = min(s_dash(p.xy, float2(0.216, -0.1695), float2(0.2593, -0.0685), 0.028, 0.042),
+ s_dash(p.xy, float2(0.3054, -0.2339), float2(0.3949, -0.1652), 0.0275, 0.041));
+ }
+ return s_opu(r, float2(s_extrude(dash, p.z - 0.11, 0.06, 0.006), 3.0));
+ }
+ if (!arrow)
+ {
+ float2 cq = p.xy - float2(0.1541, -0.9047);
+ float2 bq = abs(float2(cq.x, cq.y - 0.2066 * cq.x * cq.x)) - float2(0.214, 0.0361);
+ float cuff = length(max(bq, 0.0)) + min(max(bq.x, bq.y), 0.0) - 0.03;
+ r = s_opu(r, s_piece(cuff, cuff, cuff, p.z, 0.0477, 0.225, 0.025, 0.0, 2.0));
+ }
+ float2 c = arrow ? float2(0.6118, -0.8079) : float2(0.15, -0.885);
+ float rs = arrow ? 0.1678 : 0.128;
+ float zs = arrow ? 0.23 : 0.285;
+ float2 q = s_rot(p.xy - c, arrow ? 0.2443 : 0.0) / rs;
+ float star = (s_star5(q, 0.82, 0.55) - 0.18) * rs;
+ float leaves = min(s_vesica(s_rot(q - float2(-0.33, 1.38), -0.925), 0.4296, 0.2626),
+ s_vesica(s_rot(q - float2(0.53, 1.37), 0.873), 0.4296, 0.2626)) * rs;
+ r = s_opu(r, s_piece(leaves, leaves, leaves, p.z, 0.038, zs - 0.02, 0.018, 0.0, 4.0));
+ r = s_opu(r, s_piece(star, star, star, p.z, 0.038, zs, 0.022, 0.018 * s_bump(q, float2(0.0), 1.0), 3.0));
+ float3 e = float3(abs(q.x) - 0.25, q.y - 0.08, (p.z - zs - 0.056) / rs);
+ return s_opu(r, float2(s_ellipsoid(e, float3(0.075, 0.13, 0.1)) * rs, 5.0));
+}
+
+// Pixel Candy : tables générées par scripts/generate-pixel-candy-voxels.mjs (cf. HLSL).
+//
+constant int PIX_BODY[32] = { 1, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 127, 247, 243, 480, 192, 48, 120, 120, 120, 504, 4088, 32760, 65534, 65535, 65535, 65534, 32766, 32764, 16380, 16376, 16376 };
+constant int PIX_LINE[32] = { 1, 3, 5, 9, 17, 33, 65, 129, 257, 513, 1985, 73, 149, 147, 288, 192, 48, 72, 72, 72, 456, 3656, 29256, 37454, 32777, 32769, 32770, 16386, 16388, 8196, 8200, 16376 };
+constant int PIX_HI[32] = { 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 18, 34, 32, 64, 0, 0, 16, 16, 16, 16, 16, 16, 16, 22, 18, 4, 4, 8, 8, 16, 0 };
+constant int PIX_SHADE[32] = { 0, 0, 0, 4, 8, 16, 32, 64, 128, 448, 40, 36, 64, 64, 128, 0, 0, 32, 32, 32, 32, 288, 2336, 18720, 16384, 16384, 16384, 8192, 8192, 4096, 8160, 0 };
+constant int PIX_RECT_N[3] = { 0, 17, 29 };
+constant int4 PIX_GRID[2] = {
+ int4(11, 16, 0, 0),
+ int4(16, 16, 16, 0)
+};
+constant float2 PIX_ORIGIN[2] = {
+ float2(0.0, 0.0),
+ float2(-0.3125, 0.0)
};
-constant int SCULPT_HANDPIX[15] = { 4, 14, 14, 14, 110, 878, 7022, 7022, 8190, 8191, 8191, 8191, 8190, 4092, 4092 };
-constant int SCULPT_HANDBACK[17] = {
- 28, 62, 62, 62, 510, 4094, 32766, 32766, 32766, 32767, 32767, 32767, 32767, 32767, 32766, 16380, 16380
+constant float4 PIX_BOX[2] = {
+ float4(0.3438, -0.5, 0.3438, 0.5),
+ float4(0.1875, -0.5, 0.5, 0.5)
};
-constant float2 SCULPT_HANDSPAN[17] = {
- float2(2.0, 4.0), float2(1.0, 5.0), float2(1.0, 5.0), float2(1.0, 5.0), float2(1.0, 8.0),
- float2(1.0, 11.0), float2(1.0, 14.0), float2(1.0, 14.0), float2(1.0, 14.0), float2(0.0, 14.0),
- float2(0.0, 14.0), float2(0.0, 14.0), float2(0.0, 14.0), float2(0.0, 14.0), float2(1.0, 14.0),
- float2(2.0, 13.0), float2(2.0, 13.0)
+constant float4 PIX_RECT[29] = {
+ float4(0.0313, -0.0313, 0.0313, 0.0313),
+ float4(0.0625, -0.0938, 0.0625, 0.0313),
+ float4(0.0938, -0.1563, 0.0938, 0.0313),
+ float4(0.125, -0.2188, 0.125, 0.0313),
+ float4(0.1563, -0.2813, 0.1563, 0.0313),
+ float4(0.1875, -0.3438, 0.1875, 0.0313),
+ float4(0.2188, -0.4063, 0.2188, 0.0313),
+ float4(0.25, -0.4688, 0.25, 0.0313),
+ float4(0.2813, -0.5313, 0.2813, 0.0313),
+ float4(0.3125, -0.5938, 0.3125, 0.0313),
+ float4(0.3438, -0.6563, 0.3438, 0.0313),
+ float4(0.2188, -0.7188, 0.2188, 0.0313),
+ float4(0.0938, -0.7813, 0.0938, 0.0313),
+ float4(0.375, -0.8125, 0.125, 0.0625),
+ float4(0.0625, -0.8438, 0.0625, 0.0313),
+ float4(0.4375, -0.9063, 0.125, 0.0313),
+ float4(0.4375, -0.9688, 0.0625, 0.0313),
+ float4(0.0, -0.0313, 0.0625, 0.0313),
+ float4(0.0, -0.1563, 0.125, 0.0938),
+ float4(0.0625, -0.2813, 0.1875, 0.0313),
+ float4(0.1563, -0.3438, 0.2813, 0.0313),
+ float4(0.25, -0.4063, 0.375, 0.0313),
+ float4(0.2188, -0.4688, 0.4688, 0.0313),
+ float4(0.1875, -0.5625, 0.5, 0.0625),
+ float4(0.2188, -0.6563, 0.4688, 0.0313),
+ float4(0.1875, -0.7188, 0.4375, 0.0313),
+ float4(0.2188, -0.7813, 0.4063, 0.0313),
+ float4(0.1875, -0.8438, 0.375, 0.0313),
+ float4(0.2188, -0.9375, 0.3438, 0.0625)
};
+//
-inline float2 s_grid_origin(int shape)
+inline bool s_pix_bit(int m, int c)
{
- return shape == 0 ? float2(0.0, 0.0) : float2(-2.5 * SCULPT_VOX, 0.0);
+ return ((m >> clamp(c, 0, 31)) & 1) == 1;
}
-inline bool s_bit(int row, int r, int c, int rows, int cols)
+static bool s_pix_body(int shape, int c, int r)
{
- return r >= 0 && r < rows && c >= 0 && c < cols && ((row >> clamp(c, 0, 31)) & 1) == 1;
+ int4 g = PIX_GRID[shape];
+ return c >= 0 && c < g.x && r >= 0 && r < g.y && s_pix_bit(PIX_BODY[g.z + clamp(r, 0, g.y - 1)], c);
}
-static bool s_occ(float2 id, int shape)
+inline int2 s_pix_cell(float2 p, int shape)
{
- int c = int(id.x);
- int r = -int(id.y) - 1;
- if (shape == 0)
- {
- return s_bit(SCULPT_ARROWPIX[clamp(r, 0, 15)], r, c, 16, 16);
- }
- return s_bit(SCULPT_HANDPIX[clamp(r, 0, 14)], r, c, 15, 13);
-}
-
-static bool s_occ_back(float2 id, int shape)
-{
- int c = int(id.x) + 1;
- int r = -int(id.y);
- if (shape == 0)
- {
- return s_bit(SCULPT_ARROWBACK[clamp(r, 0, 17)], r, c, 18, 17);
- }
- return s_bit(SCULPT_HANDBACK[clamp(r, 0, 16)], r, c, 17, 15);
+ float2 o = PIX_ORIGIN[shape];
+ return int2(int(floor((p.x - o.x) / SCULPT_VOX)), int(floor((o.y - p.y) / SCULPT_VOX)));
}
static float2 s_voxels(float3 p, int shape)
{
- float2 o = s_grid_origin(shape);
- float2 cell = floor((p.xy - o) / SCULPT_VOX);
- float2 bc = shape == 0 ? float2(0.33, -0.5) : float2(0.25, -0.47);
- float2 bh = shape == 0 ? float2(0.44, 0.61) : float2(0.48, 0.55);
- float2 bq = max(abs(p.xy - bc) - bh, 0.0);
+ float4 b = PIX_BOX[shape];
+ float2 bq = max(abs(p.xy - b.xy) - b.zw, 0.0);
float bz = max(abs(p.z - (SCULPT_HOVER + 0.07)) - 0.07, 0.0);
- float far = max(SCULPT_VOX, sqrt(dot(bq, bq) + bz * bz));
- float dF = far;
- float dB = far;
- const float3 half_cell = float3(0.5 * SCULPT_VOX, 0.5 * SCULPT_VOX, 0.04);
+ float d = max(SCULPT_VOX, sqrt(dot(bq, bq) + bz * bz));
+ float2 o = PIX_ORIGIN[shape];
+ int2 cell = s_pix_cell(p.xy, shape);
+ const float3 cube = float3(0.5 * SCULPT_VOX, 0.5 * SCULPT_VOX, 0.07);
for (int j = -1; j <= 1; j++)
{
for (int i = -1; i <= 1; i++)
{
- float2 id = cell + float2(float(i), float(j));
- float3 q = float3(p.xy - (o + (id + 0.5) * SCULPT_VOX), p.z);
- if (s_occ(id, shape))
+ int c = cell.x + i;
+ int r = cell.y + j;
+ if (s_pix_body(shape, c, r))
{
- dF = min(dF, s_round_box(q - float3(0.0, 0.0, SCULPT_HOVER + 0.1), half_cell, 0.009));
- }
- if (s_occ_back(id, shape))
- {
- dB = min(dB, s_round_box(q - float3(0.0, 0.0, SCULPT_HOVER + 0.04), half_cell, 0.009));
+ float3 q = float3(p.x - o.x - (float(c) + 0.5) * SCULPT_VOX, p.y - o.y + (float(r) + 0.5) * SCULPT_VOX,
+ p.z - SCULPT_HOVER - 0.07);
+ d = min(d, s_round_box(q, cube, 0.006));
}
}
}
- return dF < dB ? float2(dF, 6.0) : float2(dB, 7.0);
+ return float2(d, 6.0);
}
-static float s_pixel_hand_dist(float2 p)
+static float s_pixel_outline(float2 p, int shape)
{
float d = 1e9;
- for (int r = 0; r < 17; r++)
+ for (int i = PIX_RECT_N[shape]; i < PIX_RECT_N[shape + 1]; i++)
{
- float2 x = (SCULPT_HANDSPAN[r] + float2(-3.5, -2.5)) * SCULPT_VOX;
- float2 y = float2(-float(r), 1.0 - float(r)) * SCULPT_VOX;
- float2 q = max(max(float2(x.x, y.x) - p, p - float2(x.y, y.y)), 0.0);
- d = min(d, length(q));
+ float2 q = abs(p - PIX_RECT[i].xy) - PIX_RECT[i].zw;
+ d = min(d, length(max(q, 0.0)) + min(max(q.x, q.y), 0.0));
}
return d;
}
-static float s_gem_edge(float3 p, float2 a, float2 b, float z0)
-{
- float2 e = b - a;
- float len = length(e);
- float2 d = e / len;
- float2 q = p.xy - a;
- float dist = dot(q, float2(-d.y, d.x));
- float along = abs(dot(q, d) - 0.5 * len);
- float z = p.z - z0;
- float girdle = (z - 0.035 - 2.2 * dist) * 0.4138;
- float crown = (z - 0.07 - 0.6 * dist + 0.18 * along) * 0.8438;
- return max(-dist, max(girdle, crown));
-}
+// ---- Prism Glow : un cristal en MAILLAGE ---- (cf. HLSL)
+// prism mesh: generated by design/cursors/prism-glow/model/export_compositor.py
+constant int PRISM_TRI_START[2] = { 0, 31 };
+constant int PRISM_TRI_COUNT[2] = { 31, 130 };
+constant int PRISM_SIL_START[2] = { 0, 25 };
+constant int PRISM_SIL_COUNT[2] = { 25, 42 };
+constant int PRISM_OUT_START[2] = { 67, 76 };
+constant int PRISM_OUT_COUNT[2] = { 9, 34 };
+constant int PRISM_BOX_START[2] = { 0, 4 };
+constant int PRISM_BOX_COUNT[2] = { 4, 18 };
+constant float4 PRISM_TRIS[644] = {
+ float4(0.252301, 0.266258, -0.042945, 11.0),
+ float4(-0.221166, -0.203988, 0.0, 0.01033),
+ float4(-0.221166, -0.203988, 0.042945, 0.045182),
+ float4(0.677984, -0.735077, 0.0, 0.577584),
+ float4(0.252301, 0.266258, -0.042945, 3.0),
+ float4(-0.221166, -0.203988, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.677984, -0.735077, 0.0, 0.577584),
+ float4(0.031135, 0.06227, -0.042945, 7.0),
+ float4(0.004294, 0.419785, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(-0.999948, 0.01023, 0.0, 0.577584),
+ float4(0.169632, 0.356442, 0.115951, 14.0),
+ float4(0.082669, -0.090184, -0.115951, 0.590616),
+ float4(-0.138497, -0.294172, -0.115951, 0.737911),
+ float4(0.466385, -0.505659, 0.725806, 0.973449),
+ float4(0.035429, 0.482055, 0.0, 14.0),
+ float4(0.134202, -0.125614, 0.115951, 0.000305),
+ float4(-0.004294, -0.419785, 0.0, 0.938689),
+ float4(-0.650191, 0.006652, 0.759742, 0.854994),
+ float4(0.031135, 0.06227, -0.042945, 9.0),
+ float4(0.004294, 0.419785, 0.0, 0.01033),
+ float4(0.004294, 0.419785, 0.042945, 0.045182),
+ float4(-0.999948, 0.01023, 0.0, 0.577584),
+ float4(0.169632, 0.356442, 0.115951, 14.0),
+ float4(0.159969, 0.080521, -0.045092, 0.381323),
+ float4(0.082669, -0.090184, -0.115951, 0.056123),
+ float4(0.461407, -0.510214, 0.725799, 0.973449),
+ float4(0.541104, 0.544325, -0.042945, 9.0),
+ float4(-0.288804, -0.278068, 0.0, 0.01033),
+ float4(-0.288804, -0.278068, 0.042945, 0.045182),
+ float4(0.69359, -0.72037, 0.0, 0.577584),
+ float4(0.329601, 0.436963, 0.070859, 14.0),
+ float4(0.211503, 0.107362, -0.070859, 0.545724),
+ float4(-0.077301, -0.170706, -0.070859, 0.064805),
+ float4(0.49567, -0.514808, 0.699488, 0.973449),
+ float4(0.268405, 0.533589, 0.081595, 14.0),
+ float4(0.061196, -0.096626, -0.010736, 0.000916),
+ float4(-0.098773, -0.177147, 0.034356, 0.046662),
+ float4(0.247834, 0.049458, 0.967539, 0.904662),
+ float4(0.541104, 0.544325, -0.042945, 7.0),
+ float4(-0.288804, -0.278068, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.69359, -0.72037, 0.0, 0.577584),
+ float4(0.199693, 0.595859, 0.0, 14.0),
+ float4(0.068712, -0.06227, 0.081595, 0.0),
+ float4(-0.030061, -0.239417, 0.115951, 0.01445),
+ float4(-0.504461, 0.426837, 0.750553, 0.254154),
+ float4(0.268405, 0.533589, 0.081595, 14.0),
+ float4(0.272699, 0.010736, -0.081595, 0.0),
+ float4(0.061196, -0.096626, -0.010736, 0.025193),
+ float4(0.283244, 0.073137, 0.956255, 0.527108),
+ float4(0.035429, 0.482055, 0.0, 14.0),
+ float4(0.0, 0.258742, 0.0, 0.0),
+ float4(0.134202, -0.125614, 0.115951, 0.473533),
+ float4(-0.653777, 0.0, 0.756687, 0.964691),
+ float4(0.359663, 0.559356, 0.0, 14.0),
+ float4(0.181442, -0.015031, 0.0, 0.006043),
+ float4(-0.091258, -0.025767, 0.081595, 0.076188),
+ float4(0.076465, 0.923048, 0.37701, 0.964691),
+ float4(0.035429, 0.482055, -0.042945, 3.0),
+ float4(0.0, 0.258742, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(-1.0, 0.0, 0.0, 0.577584),
+ float4(0.035429, 0.482055, -0.042945, 11.0),
+ float4(0.0, 0.258742, 0.0, 0.01033),
+ float4(0.0, 0.258742, 0.042945, 0.045182),
+ float4(-1.0, 0.0, 0.0, 0.577584),
+ float4(0.035429, 0.740798, -0.042945, 7.0),
+ float4(0.164264, -0.144939, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.661622, 0.749838, 0.0, 0.577584),
+ float4(0.035429, 0.740798, 0.0, 14.0),
+ float4(0.164264, -0.144939, 0.0, 0.0),
+ float4(0.134202, -0.384356, 0.115951, 0.010956),
+ float4(0.332572, 0.376915, 0.864483, 0.215854),
+ float4(0.035429, 0.740798, -0.042945, 11.0),
+ float4(0.164264, -0.144939, 0.0, 0.01033),
+ float4(0.164264, -0.144939, 0.042945, 0.045182),
+ float4(0.661622, 0.749838, 0.0, 0.577584),
+ float4(0.199693, 0.595859, -0.042945, 7.0),
+ float4(0.105215, 0.22546, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(-0.906183, 0.422886, 0.0, 0.577584),
+ float4(0.199693, 0.595859, 0.0, 14.0),
+ float4(0.105215, 0.22546, 0.0, 0.0),
+ float4(0.068712, -0.06227, 0.081595, 0.015991),
+ float4(-0.613882, 0.286478, 0.735581, 0.473533),
+ float4(0.199693, 0.595859, -0.042945, 11.0),
+ float4(0.105215, 0.22546, 0.0, 0.01033),
+ float4(0.105215, 0.22546, 0.042945, 0.045182),
+ float4(-0.906183, 0.422886, 0.0, 0.577584),
+ float4(0.304908, 0.821319, 0.0, 14.0),
+ float4(0.128834, -0.067638, 0.0, 0.0),
+ float4(-0.036503, -0.28773, 0.081595, 0.254154),
+ float4(0.133686, 0.254641, 0.957751, 0.973449),
+ float4(0.304908, 0.821319, -0.042945, 7.0),
+ float4(0.128834, -0.067638, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.464834, 0.885398, 0.0, 0.577584),
+ float4(0.433742, 0.753681, 0.0, 14.0),
+ float4(-0.07408, -0.194325, 0.0, 0.0),
+ float4(-0.165337, -0.220092, 0.081595, 0.964691),
+ float4(0.683362, -0.260508, 0.682021, 0.964691),
+ float4(0.433742, 0.753681, -0.042945, 11.0),
+ float4(-0.07408, -0.194325, 0.0, 0.01033),
+ float4(-0.07408, -0.194325, 0.042945, 0.045182),
+ float4(0.934406, -0.35621, 0.0, 0.577584),
+ float4(0.304908, 0.821319, -0.042945, 11.0),
+ float4(0.128834, -0.067638, 0.0, 0.01033),
+ float4(0.128834, -0.067638, 0.042945, 0.045182),
+ float4(0.464834, 0.885398, 0.0, 0.577584),
+ float4(0.433742, 0.753681, -0.042945, 7.0),
+ float4(-0.07408, -0.194325, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.934406, -0.35621, 0.0, 0.577584),
+ float4(0.359663, 0.559356, -0.042945, 7.0),
+ float4(0.181442, -0.015031, 0.042945, 0.01033),
+ float4(0.0, 0.0, 0.042945, 0.045182),
+ float4(0.082557, 0.996586, 0.0, 0.577584),
+ float4(0.359663, 0.559356, -0.042945, 11.0),
+ float4(0.181442, -0.015031, 0.0, 0.01033),
+ float4(0.181442, -0.015031, 0.042945, 0.045182),
+ float4(0.082557, 0.996586, 0.0, 0.577584),
+ float4(0.044345, 0.043263, -0.043263, 11.0),
+ float4(-0.043263, -0.005408, 0.0, 0.01033),
+ float4(-0.043263, -0.005408, 0.043263, 0.045182),
+ float4(0.124035, -0.992278, 0.0, 0.577584),
+ float4(0.044345, 0.043263, -0.043263, 7.0),
+ float4(-0.043263, -0.005408, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.124035, -0.992278, 0.0, 0.577584),
+ float4(0.001082, 0.037855, -0.043263, 7.0),
+ float4(-0.044345, 0.040019, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.669964, -0.742393, 0.0, 0.577584),
+ float4(0.044345, 0.043263, 0.0, 10.0),
+ float4(-0.043263, -0.005408, 0.0, 0.000305),
+ float4(-0.040019, 0.030284, 0.049753, 0.964691),
+ float4(0.101434, -0.811469, 0.575525, 0.973449),
+ float4(0.004326, 0.073548, 0.049753, 14.0),
+ float4(-0.003245, -0.035692, -0.049753, 0.0),
+ float4(-0.04759, 0.004326, -0.049753, 0.955978),
+ float4(-0.580475, -0.643229, 0.499304, 0.896269),
+ float4(0.001082, 0.037855, -0.043263, 11.0),
+ float4(-0.044345, 0.040019, 0.0, 0.01033),
+ float4(-0.044345, 0.040019, 0.043263, 0.045182),
+ float4(-0.669964, -0.742393, 0.0, 0.577584),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.035692, -0.155748, -0.008653, 0.000305),
+ float4(-0.011897, -0.151422, -0.058405, 0.955978),
+ float4(-0.716533, -0.20131, 0.667874, 0.904662),
+ float4(-0.043263, 0.077874, -0.043263, 7.0),
+ float4(-0.001082, 0.154666, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999976, -0.006993, 0.0, 0.577584),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.089771, -0.137361, -0.02055, 0.445197),
+ float4(0.035692, -0.155748, -0.008653, 0.973449),
+ float4(0.216369, -0.004654, 0.976301, 0.973449),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(-0.011897, -0.151422, -0.058405, 0.000305),
+ float4(-0.012979, 0.003245, -0.058405, 0.955978),
+ float4(-0.976245, -0.006827, 0.216564, 0.904662),
+ float4(-0.043263, 0.077874, -0.043263, 9.0),
+ float4(-0.001082, 0.154666, 0.0, 0.01033),
+ float4(-0.001082, 0.154666, 0.043263, 0.045182),
+ float4(-0.999976, -0.006993, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, 0.052998, 14.0),
+ float4(0.014061, -0.335291, 0.005408, 0.520989),
+ float4(0.001082, -0.332046, -0.052998, 0.088655),
+ float4(-0.975941, -0.037462, 0.214794, 0.973449),
+ float4(-0.044345, 0.23254, 0.0, 3.0),
+ float4(0.0, 0.0, -0.043263, 0.01033),
+ float4(-0.001082, 0.332046, -0.043263, 0.045182),
+ float4(-0.999995, -0.003257, 0.0, 0.577584),
+ float4(-0.163319, 0.380717, -0.043263, 7.0),
+ float4(-0.060569, 0.02704, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.407651, -0.913138, 0.0, 0.577584),
+ float4(-0.094098, 0.431551, -0.043263, 11.0),
+ float4(-0.069221, -0.050834, 0.0, 0.01033),
+ float4(-0.069221, -0.050834, 0.043263, 0.045182),
+ float4(0.591909, -0.806004, 0.0, 0.577584),
+ float4(-0.163319, 0.380717, -0.043263, 11.0),
+ float4(-0.060569, 0.02704, 0.0, 0.01033),
+ float4(-0.060569, 0.02704, 0.043263, 0.045182),
+ float4(-0.407651, -0.913138, 0.0, 0.577584),
+ float4(0.075711, 0.08869, -0.043263, 11.0),
+ float4(-0.031366, -0.045426, 0.0, 0.01033),
+ float4(-0.031366, -0.045426, 0.043263, 0.045182),
+ float4(0.822897, -0.568191, 0.0, 0.577584),
+ float4(0.044345, 0.043263, 0.0, 6.0),
+ float4(-0.040019, 0.030284, 0.049753, 0.000305),
+ float4(0.014061, 0.048671, 0.037855, 0.964691),
+ float4(0.365607, -0.634949, 0.680566, 0.973449),
+ float4(0.075711, 0.08869, -0.043263, 7.0),
+ float4(-0.031366, -0.045426, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.822897, -0.568191, 0.0, 0.577584),
+ float4(0.058405, 0.091934, 0.037855, 14.0),
+ float4(0.017305, -0.003245, -0.037855, 0.0),
+ float4(-0.014061, -0.048671, -0.037855, 0.473533),
+ float4(0.757369, -0.522945, 0.39105, 0.904662),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.107077, -0.140606, -0.058405, 0.0),
+ float4(0.089771, -0.137361, -0.02055, 0.376257),
+ float4(0.812052, 0.481346, 0.329966, 0.830777),
+ float4(0.077874, 0.407756, -0.043263, 11.0),
+ float4(-0.002163, -0.319067, 0.0, 0.01033),
+ float4(-0.002163, -0.319067, 0.043263, 0.045182),
+ float4(0.999977, -0.006779, 0.0, 0.577584),
+ float4(-0.031366, 0.229295, 0.058405, 14.0),
+ float4(0.10924, 0.178461, -0.058405, 0.0),
+ float4(0.107077, -0.140606, -0.058405, 0.376257),
+ float4(0.475583, -0.003224, 0.879665, 0.830777),
+ float4(0.147095, 0.272559, -0.043263, 7.0),
+ float4(-0.035692, 0.034611, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.696146, -0.7179, 0.0, 0.577584),
+ float4(0.147095, 0.272559, -0.043263, 11.0),
+ float4(-0.035692, 0.034611, 0.0, 0.01033),
+ float4(-0.035692, 0.034611, 0.043263, 0.045182),
+ float4(-0.696146, -0.7179, 0.0, 0.577584),
+ float4(0.144932, 0.307169, 0.049753, 14.0),
+ float4(0.002163, -0.034611, -0.049753, 0.0),
+ float4(-0.033529, 0.0, -0.049753, 0.9131),
+ float4(-0.630237, -0.649931, 0.424725, 0.904662),
+ float4(0.183869, 0.317985, 0.037855, 6.0),
+ float4(-0.036774, -0.045426, -0.037855, 0.0),
+ float4(-0.038937, -0.010816, 0.011897, 0.806958),
+ float4(0.374433, -0.753477, 0.540438, 0.854994),
+ float4(0.077874, 0.407756, -0.043263, 7.0),
+ float4(-0.002163, -0.319067, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.999977, -0.006779, 0.0, 0.577584),
+ float4(0.111403, 0.307169, -0.043263, 7.0),
+ float4(-0.002163, 0.131953, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999866, -0.016391, 0.0, 0.577584),
+ float4(0.10924, 0.439122, 0.0, 6.0),
+ float4(0.035692, -0.131953, 0.049753, 0.18117),
+ float4(0.002163, -0.131953, 0.0, 0.955978),
+ float4(-0.82919, -0.013593, 0.558802, 0.964691),
+ float4(0.128708, 0.463999, 0.063813, 14.0),
+ float4(0.055161, -0.146014, -0.025958, 0.246204),
+ float4(0.016224, -0.156829, -0.014061, 0.964691),
+ float4(0.305397, -0.053644, 0.950713, 0.973449),
+ float4(0.111403, 0.307169, -0.043263, 11.0),
+ float4(-0.002163, 0.131953, 0.0, 0.01033),
+ float4(-0.002163, 0.131953, 0.043263, 0.045182),
+ float4(-0.999866, -0.016391, 0.0, 0.577584),
+ float4(0.189277, 0.28013, -0.043263, 11.0),
+ float4(-0.042182, -0.007571, 0.0, 0.01033),
+ float4(-0.042182, -0.007571, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.183869, 0.317985, 0.037855, 10.0),
+ float4(0.005408, -0.037855, -0.037855, 0.0),
+ float4(-0.036774, -0.045426, -0.037855, 0.806958),
+ float4(0.124328, -0.692684, 0.710445, 0.854994),
+ float4(0.189277, 0.28013, -0.043263, 7.0),
+ float4(-0.042182, -0.007571, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.183869, 0.317985, 0.037855, 14.0),
+ float4(0.02704, -0.004326, -0.037855, 0.0),
+ float4(0.005408, -0.037855, -0.037855, 0.514916),
+ float4(0.70062, -0.452013, 0.552101, 0.846876),
+ float4(0.210908, 0.313659, -0.043263, 11.0),
+ float4(-0.021632, -0.033529, 0.0, 0.01033),
+ float4(-0.021632, -0.033529, 0.043263, 0.045182),
+ float4(0.840297, -0.542127, 0.0, 0.577584),
+ float4(0.210908, 0.445612, 0.0, 10.0),
+ float4(0.0, -0.131953, 0.0, 0.0),
+ float4(-0.02704, -0.127627, 0.037855, 0.439658),
+ float4(0.813734, 0.0, 0.581238, 0.871366),
+ float4(0.210908, 0.313659, -0.043263, 7.0),
+ float4(-0.021632, -0.033529, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.840297, -0.542127, 0.0, 0.577584),
+ float4(0.210908, 0.445612, -0.043263, 11.0),
+ float4(0.0, -0.131953, 0.0, 0.01033),
+ float4(0.0, -0.131953, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.241193, 0.343943, -0.043263, 7.0),
+ float4(-0.002163, 0.107077, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999796, -0.020198, 0.0, 0.577584),
+ float4(0.276885, 0.311496, -0.043263, 11.0),
+ float4(-0.035692, 0.032447, 0.0, 0.01033),
+ float4(-0.035692, 0.032447, 0.043263, 0.045182),
+ float4(-0.672673, -0.73994, 0.0, 0.577584),
+ float4(0.23903, 0.45102, 0.0, 14.0),
+ float4(0.042182, -0.108158, 0.046508, 0.000305),
+ float4(0.002163, -0.107077, 0.0, 0.846876),
+ float4(-0.758098, -0.015315, 0.651961, 0.896269),
+ float4(0.276885, 0.311496, -0.043263, 7.0),
+ float4(-0.035692, 0.032447, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.672673, -0.73994, 0.0, 0.577584),
+ float4(0.281211, 0.342862, 0.046508, 14.0),
+ float4(-0.004326, -0.031366, -0.046508, 0.0),
+ float4(-0.040019, 0.001082, -0.046508, 0.752941),
+ float4(-0.58651, -0.645161, 0.489667, 0.846876),
+ float4(0.246601, 0.459672, 0.06165, 14.0),
+ float4(0.070303, -0.101669, -0.024876, 0.262257),
+ float4(0.034611, -0.116811, -0.015142, 0.973449),
+ float4(0.27928, -0.041605, 0.959308, 0.973449),
+ float4(0.319067, 0.319067, -0.043263, 11.0),
+ float4(-0.042182, -0.007571, 0.0, 0.01033),
+ float4(-0.042182, -0.007571, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.319067, 0.319067, 0.0, 10.0),
+ float4(-0.042182, -0.007571, 0.0, 0.0),
+ float4(-0.037855, 0.023795, 0.046508, 0.896269),
+ float4(0.1483, -0.826245, 0.54344, 0.896269),
+ float4(0.319067, 0.319067, -0.043263, 7.0),
+ float4(-0.042182, -0.007571, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.176664, -0.984271, 0.0, 0.577584),
+ float4(0.319067, 0.319067, 0.0, 6.0),
+ float4(-0.037855, 0.023795, 0.046508, 0.0),
+ float4(-0.002163, 0.038937, 0.036774, 0.896269),
+ float4(0.437906, -0.604311, 0.665618, 0.896269),
+ float4(0.316904, 0.358004, 0.036774, 14.0),
+ float4(0.024876, -0.004326, -0.036774, 0.0),
+ float4(0.002163, -0.038937, -0.036774, 0.617212),
+ float4(0.707338, -0.464191, 0.533104, 0.921584),
+ float4(0.34178, 0.353677, -0.043263, 11.0),
+ float4(-0.022713, -0.034611, 0.0, 0.01033),
+ float4(-0.022713, -0.034611, 0.043263, 0.045182),
+ float4(0.836048, -0.548657, 0.0, 0.577584),
+ float4(0.340698, 0.474815, 0.0, 14.0),
+ float4(0.001082, -0.121137, 0.0, 0.0),
+ float4(-0.023795, -0.116811, 0.036774, 0.473533),
+ float4(0.828664, 0.007399, 0.559697, 0.930114),
+ float4(0.34178, 0.353677, -0.043263, 7.0),
+ float4(-0.022713, -0.034611, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.836048, -0.548657, 0.0, 0.577584),
+ float4(0.340698, 0.474815, -0.043263, 11.0),
+ float4(0.001082, -0.121137, 0.0, 0.01033),
+ float4(0.001082, -0.121137, 0.043263, 0.045182),
+ float4(0.99996, 0.008928, 0.0, 0.577584),
+ float4(0.411001, 0.351514, -0.043263, 11.0),
+ float4(-0.034611, 0.030284, 0.0, 0.01033),
+ float4(-0.034611, 0.030284, 0.043263, 0.045182),
+ float4(-0.658505, -0.752577, 0.0, 0.577584),
+ float4(0.411001, 0.351514, -0.043263, 7.0),
+ float4(-0.034611, 0.030284, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.658505, -0.752577, 0.0, 0.577584),
+ float4(0.412083, 0.38288, 0.043263, 14.0),
+ float4(-0.001082, -0.031366, -0.043263, 0.0),
+ float4(-0.035692, -0.001082, -0.043263, 0.745403),
+ float4(-0.574039, -0.656045, 0.489984, 0.854994),
+ float4(0.45102, 0.359085, -0.043263, 11.0),
+ float4(-0.040019, -0.007571, 0.0, 0.01033),
+ float4(-0.040019, -0.007571, 0.043263, 0.045182),
+ float4(0.185892, -0.98257, 0.0, 0.577584),
+ float4(0.45102, 0.359085, 0.0, 10.0),
+ float4(-0.040019, -0.007571, 0.0, 0.000305),
+ float4(-0.038937, 0.023795, 0.043263, 0.879622),
+ float4(0.151736, -0.802035, 0.577682, 0.9131),
+ float4(0.45102, 0.359085, -0.043263, 7.0),
+ float4(-0.040019, -0.007571, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.185892, -0.98257, 0.0, 0.577584),
+ float4(0.45102, 0.359085, 0.0, 6.0),
+ float4(-0.038937, 0.023795, 0.043263, 0.000305),
+ float4(0.003245, 0.0411, 0.032447, 0.879622),
+ float4(0.417852, -0.583049, 0.696744, 0.9131),
+ float4(0.473733, 0.391533, -0.043263, 11.0),
+ float4(-0.022713, -0.032447, 0.0, 0.01033),
+ float4(-0.022713, -0.032447, 0.043263, 0.045182),
+ float4(0.819232, -0.573462, 0.0, 0.577584),
+ float4(0.454264, 0.400185, 0.032447, 14.0),
+ float4(0.019468, -0.008653, -0.032447, 0.0),
+ float4(-0.003245, -0.0411, -0.032447, 0.527108),
+ float4(0.688617, -0.482032, 0.541712, 0.887922),
+ float4(0.473733, 0.391533, -0.043263, 7.0),
+ float4(-0.022713, -0.032447, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.819232, -0.573462, 0.0, 0.577584),
+ float4(-0.223888, 0.407756, -0.043263, 7.0),
+ float4(0.002163, 0.06814, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999496, 0.03173, 0.0, 0.577584),
+ float4(-0.223888, 0.407756, -0.043263, 11.0),
+ float4(0.002163, 0.06814, 0.0, 0.01033),
+ float4(0.002163, 0.06814, 0.043263, 0.045182),
+ float4(-0.999496, 0.03173, 0.0, 0.577584),
+ float4(-0.167645, 0.416409, 0.046508, 14.0),
+ float4(-0.056242, -0.008653, -0.046508, 0.000305),
+ float4(-0.054079, 0.059487, -0.046508, 0.955978),
+ float4(-0.638985, 0.020285, 0.768952, 0.973449),
+ float4(-0.167645, 0.416409, 0.046508, 14.0),
+ float4(0.004326, -0.035692, -0.046508, 0.000305),
+ float4(-0.056242, -0.008653, -0.046508, 0.955978),
+ float4(-0.339782, -0.761112, 0.552501, 0.973449),
+ float4(-0.221724, 0.475896, 0.0, 14.0),
+ float4(0.139524, 0.055161, 0.043263, 0.520989),
+ float4(0.054079, -0.059487, 0.046508, 0.973449),
+ float4(-0.39307, 0.317373, 0.863001, 0.973449),
+ float4(-0.167645, 0.416409, 0.046508, 6.0),
+ float4(0.073548, 0.015142, -0.046508, 0.0),
+ float4(0.004326, -0.035692, -0.046508, 0.768154),
+ float4(0.490917, -0.668482, 0.558688, 0.871366),
+ float4(-0.094098, 0.431551, -0.043263, 7.0),
+ float4(-0.069221, -0.050834, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.591909, -0.806004, 0.0, 0.577584),
+ float4(-0.167645, 0.416409, 0.046508, 10.0),
+ float4(0.085445, 0.114648, -0.003245, 0.0),
+ float4(0.073548, 0.015142, -0.046508, 0.768154),
+ float4(0.548356, -0.38771, 0.740936, 0.871366),
+ float4(-0.0822, 0.531057, -0.043263, 11.0),
+ float4(-0.011897, -0.099506, 0.0, 0.01033),
+ float4(-0.011897, -0.099506, 0.043263, 0.045182),
+ float4(0.992928, -0.11872, 0.0, 0.577584),
+ float4(-0.044345, 0.23254, 0.0, 7.0),
+ float4(-0.001082, 0.332046, -0.043263, 0.01033),
+ float4(-0.001082, 0.332046, 0.052998, 0.045182),
+ float4(-0.999995, -0.003257, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, 0.052998, 14.0),
+ float4(0.1233, -0.156829, -0.052998, 0.520989),
+ float4(0.014061, -0.335291, 0.005408, 0.088655),
+ float4(0.429355, 0.032562, 0.902549, 0.973449),
+ float4(0.10924, 0.439122, 0.0, 14.0),
+ float4(-0.031366, -0.031366, 0.0, 0.132876),
+ float4(-0.154666, 0.125464, 0.052998, 0.028428),
+ float4(0.182761, -0.182761, 0.966021, 0.955978),
+ float4(0.10924, 0.439122, -0.043263, 11.0),
+ float4(-0.031366, -0.031366, 0.0, 0.01033),
+ float4(-0.031366, -0.031366, 0.043263, 0.045182),
+ float4(0.707107, -0.707107, 0.0, 0.577584),
+ float4(0.10924, 0.439122, -0.043263, 7.0),
+ float4(-0.031366, -0.031366, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.707107, -0.707107, 0.0, 0.577584),
+ float4(0.10924, 0.439122, 0.0, 10.0),
+ float4(0.019468, 0.024876, 0.063813, 0.18117),
+ float4(0.035692, -0.131953, 0.049753, 0.955978),
+ float4(-0.933936, -0.126584, 0.334276, 0.964691),
+ float4(0.128708, 0.463999, 0.063813, 14.0),
+ float4(0.050834, 0.002163, -0.005408, 0.0),
+ float4(0.0822, -0.018387, -0.063813, 0.439658),
+ float4(0.07859, -0.92643, 0.368172, 0.871366),
+ float4(-0.221724, 0.475896, -0.043263, 7.0),
+ float4(0.122219, 0.207664, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.861819, 0.507216, 0.0, 0.577584),
+ float4(-0.221724, 0.475896, -0.043263, 9.0),
+ float4(0.122219, 0.207664, 0.0, 0.01033),
+ float4(0.122219, 0.207664, 0.043263, 0.045182),
+ float4(-0.861819, 0.507216, 0.0, 0.577584),
+ float4(-0.099506, 0.68356, 0.0, 14.0),
+ float4(0.017305, -0.152503, 0.043263, 0.0),
+ float4(-0.122219, -0.207664, 0.0, 0.723049),
+ float4(-0.365879, 0.215335, 0.905408, 0.854994),
+ float4(-0.0822, 0.531057, -0.043263, 7.0),
+ float4(-0.011897, -0.099506, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.086527, 0.045182),
+ float4(0.992928, -0.11872, 0.0, 0.577584),
+ float4(-0.099506, 0.68356, 0.0, 14.0),
+ float4(0.054079, -0.118974, 0.052998, 0.0),
+ float4(0.017305, -0.152503, 0.043263, 0.514916),
+ float4(-0.41958, 0.203352, 0.884647, 0.863157),
+ float4(-0.099506, 0.68356, -0.043263, 3.0),
+ float4(0.086527, 0.149258, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.86514, 0.501531, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, -0.043263, 11.0),
+ float4(-0.036774, -0.033529, 0.0, 0.01033),
+ float4(-0.036774, -0.033529, 0.086527, 0.045182),
+ float4(0.673754, -0.738956, 0.0, 0.577584),
+ float4(-0.045426, 0.564586, -0.043263, 7.0),
+ float4(-0.036774, -0.033529, 0.086527, 0.01033),
+ float4(0.0, 0.0, 0.096261, 0.045182),
+ float4(0.673754, -0.738956, 0.0, 0.577584),
+ float4(-0.099506, 0.68356, 0.0, 14.0),
+ float4(0.086527, 0.149258, 0.0, 0.0),
+ float4(0.054079, -0.118974, 0.052998, 0.262257),
+ float4(-0.385563, 0.223515, 0.8952, 0.9131),
+ float4(-0.099506, 0.68356, -0.043263, 11.0),
+ float4(0.086527, 0.149258, 0.0, 0.01033),
+ float4(0.086527, 0.149258, 0.043263, 0.045182),
+ float4(-0.86514, 0.501531, 0.0, 0.577584),
+ float4(-0.012979, 0.832818, 0.0, 14.0),
+ float4(0.214153, -0.194685, 0.0822, 0.000305),
+ float4(-0.032447, -0.268232, 0.052998, 0.930114),
+ float4(-0.176863, 0.211325, 0.961281, 0.973449),
+ float4(0.128708, 0.463999, 0.063813, 14.0),
+ float4(-0.019468, -0.024876, -0.063813, 0.168276),
+ float4(-0.174135, 0.100587, -0.010816, 0.038209),
+ float4(-0.469238, -0.764881, 0.441331, 0.955978),
+ float4(0.201174, 0.638133, 0.0822, 14.0),
+ float4(-0.072466, -0.174135, -0.018387, 0.18117),
+ float4(-0.246601, -0.073548, -0.029203, 0.082277),
+ float4(-0.098561, -0.063844, 0.993081, 0.973449),
+ float4(-0.012979, 0.832818, -0.043263, 7.0),
+ float4(0.339617, -0.002163, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.006369, 0.99998, 0.0, 0.577584),
+ float4(0.179543, 0.466162, 0.058405, 10.0),
+ float4(-0.050834, -0.002163, 0.005408, 0.000305),
+ float4(0.021632, 0.171972, 0.023795, 0.022171),
+ float4(0.110896, -0.149888, 0.982464, 0.381323),
+ float4(-0.012979, 0.832818, 0.0, 14.0),
+ float4(0.339617, -0.002163, 0.0, 0.0),
+ float4(0.214153, -0.194685, 0.0822, 0.027314),
+ float4(0.002492, 0.391296, 0.920261, 0.49102),
+ float4(0.183869, 0.317985, 0.037855, 10.0),
+ float4(-0.055161, 0.146014, 0.025958, 0.0),
+ float4(0.02704, 0.127627, -0.037855, 0.439658),
+ float4(0.623841, 0.097823, 0.775405, 0.871366),
+ float4(0.210908, 0.445612, -0.043263, 7.0),
+ float4(0.0, -0.131953, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.210908, 0.445612, 0.0, 10.0),
+ float4(-0.031366, 0.02055, 0.058405, 0.000305),
+ float4(0.035692, 0.014061, 0.06165, 0.022171),
+ float4(-0.105864, -0.954447, 0.278971, 0.381323),
+ float4(0.23903, 0.45102, -0.043263, 11.0),
+ float4(-0.028121, -0.005408, 0.0, 0.01033),
+ float4(-0.028121, -0.005408, 0.043263, 0.045182),
+ float4(0.188847, -0.982006, 0.0, 0.577584),
+ float4(0.23903, 0.45102, -0.043263, 7.0),
+ float4(-0.028121, -0.005408, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.188847, -0.982006, 0.0, 0.577584),
+ float4(0.246601, 0.459672, 0.06165, 11.0),
+ float4(-0.007571, -0.008653, -0.06165, 0.000305),
+ float4(-0.035692, -0.014061, -0.06165, 0.022171),
+ float4(0.187618, -0.975617, 0.113888, 0.381323),
+ float4(0.241193, 0.343943, -0.043263, 11.0),
+ float4(-0.002163, 0.107077, 0.0, 0.01033),
+ float4(-0.002163, 0.107077, 0.043263, 0.045182),
+ float4(-0.999796, -0.020198, 0.0, 0.577584),
+ float4(0.23903, 0.45102, 0.0, 15.0),
+ float4(0.007571, 0.008653, 0.06165, 0.000305),
+ float4(0.042182, -0.108158, 0.046508, 0.846876),
+ float4(-0.94107, -0.299262, 0.157572, 0.896269),
+ float4(0.316904, 0.358004, 0.036774, 14.0),
+ float4(-0.070303, 0.101669, 0.024876, 0.0),
+ float4(0.023795, 0.116811, -0.036774, 0.473533),
+ float4(0.523424, 0.157027, 0.837478, 0.930114),
+ float4(0.340698, 0.474815, -0.043263, 7.0),
+ float4(0.001082, -0.121137, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.99996, 0.008928, 0.0, 0.577584),
+ float4(0.376391, 0.381799, -0.043263, 11.0),
+ float4(-0.001082, 0.107077, 0.0, 0.01033),
+ float4(-0.001082, 0.107077, 0.043263, 0.045182),
+ float4(-0.999949, -0.0101, 0.0, 0.577584),
+ float4(0.376391, 0.381799, -0.043263, 7.0),
+ float4(-0.001082, 0.107077, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(-0.999949, -0.0101, 0.0, 0.577584),
+ float4(0.375309, 0.488875, 0.0, 14.0),
+ float4(0.036774, -0.105995, 0.043263, 0.000305),
+ float4(0.001082, -0.107077, 0.0, 0.879622),
+ float4(-0.771254, -0.00779, 0.63648, 0.896269),
+ float4(0.375309, 0.488875, 0.0, 14.0),
+ float4(0.078955, -0.08869, 0.032447, 0.223224),
+ float4(0.036774, -0.105995, 0.043263, 0.964691),
+ float4(0.071225, 0.39802, 0.914608, 0.973449),
+ float4(0.473733, 0.603523, 0.0, 14.0),
+ float4(0.0, -0.21199, 0.0, 0.158953),
+ float4(-0.019468, -0.203337, 0.032447, 0.030716),
+ float4(0.857493, 0.0, 0.514496, 0.964691),
+ float4(0.473733, 0.603523, -0.043263, 11.0),
+ float4(0.0, -0.21199, 0.0, 0.01033),
+ float4(0.0, -0.21199, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.276885, 0.484549, 0.058405, 10.0),
+ float4(-0.030284, -0.024876, 0.003245, 0.000305),
+ float4(-0.097342, -0.018387, 0.0, 0.022171),
+ float4(-0.03153, 0.166923, 0.985466, 0.381323),
+ float4(0.246601, 0.459672, 0.06165, 14.0),
+ float4(0.030284, 0.024876, -0.003245, 0.0),
+ float4(0.094098, 0.015142, -0.06165, 0.473533),
+ float4(0.518869, -0.545855, 0.657888, 0.930114),
+ float4(0.375309, 0.488875, -0.043263, 11.0),
+ float4(-0.034611, -0.014061, 0.0, 0.01033),
+ float4(-0.034611, -0.014061, 0.043263, 0.045182),
+ float4(0.376377, -0.926467, 0.0, 0.577584),
+ float4(0.340698, 0.474815, 0.0, 14.0),
+ float4(-0.063813, 0.009734, 0.058405, 0.000305),
+ float4(0.034611, 0.014061, 0.0, 0.022171),
+ float4(0.3276, -0.806402, 0.492334, 0.381323),
+ float4(0.375309, 0.488875, -0.043263, 7.0),
+ float4(-0.034611, -0.014061, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.376377, -0.926467, 0.0, 0.577584),
+ float4(0.454264, 0.400185, 0.032447, 12.0),
+ float4(-0.078955, 0.08869, -0.032447, 0.158953),
+ float4(0.019468, 0.203337, -0.032447, 0.030716),
+ float4(-0.201684, 0.173144, 0.964025, 0.964691),
+ float4(0.201174, 0.638133, 0.0822, 8.0),
+ float4(0.075711, -0.153585, -0.023795, 0.000305),
+ float4(-0.021632, -0.171972, -0.023795, 0.022171),
+ float4(0.026497, -0.140281, 0.989757, 0.381323),
+ float4(0.473733, 0.603523, -0.043263, 7.0),
+ float4(0.0, -0.21199, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(1.0, 0.0, 0.0, 0.577584),
+ float4(0.276885, 0.484549, 0.058405, 14.0),
+ float4(-0.075711, 0.153585, 0.023795, 0.000305),
+ float4(0.098424, 0.004326, -0.058405, 0.022171),
+ float4(0.50316, 0.115345, 0.856461, 0.381323),
+ float4(0.375309, 0.488875, 0.0, 10.0),
+ float4(0.090853, 0.12979, 0.0, 0.158953),
+ float4(0.098424, 0.114648, 0.0, 0.030716),
+ float4(0.0, 0.0, 1.0, 0.964691),
+ float4(0.466162, 0.618665, -0.043263, 11.0),
+ float4(0.007571, -0.015142, 0.0, 0.01033),
+ float4(0.007571, -0.015142, 0.043263, 0.045182),
+ float4(0.894427, 0.447214, 0.0, 0.577584),
+ float4(0.466162, 0.618665, -0.043263, 3.0),
+ float4(0.007571, -0.015142, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.894427, 0.447214, 0.0, 0.577584),
+ float4(0.326638, 0.830655, 0.0, 14.0),
+ float4(0.139524, -0.21199, 0.0, 0.0),
+ float4(0.048671, -0.34178, 0.0, 0.015213),
+ float4(0.0, 0.0, 1.0, 0.428687),
+ float4(0.326638, 0.830655, 0.0, 14.0),
+ float4(0.048671, -0.34178, 0.0, 0.007492),
+ float4(-0.125464, -0.192522, 0.0822, 0.054475),
+ float4(0.472493, 0.067285, 0.878762, 0.964691),
+ float4(0.326638, 0.830655, -0.043263, 9.0),
+ float4(0.139524, -0.21199, 0.0, 0.01033),
+ float4(0.139524, -0.21199, 0.043263, 0.045182),
+ float4(0.835314, 0.549773, 0.0, 0.577584),
+ float4(0.326638, 0.830655, -0.043263, 7.0),
+ float4(0.139524, -0.21199, 0.043263, 0.01033),
+ float4(0.0, 0.0, 0.043263, 0.045182),
+ float4(0.835314, 0.549773, 0.0, 0.577584),
+ float4(-0.012979, 0.832818, -0.043263, 11.0),
+ float4(0.339617, -0.002163, 0.0, 0.01033),
+ float4(0.339617, -0.002163, 0.043263, 0.045182),
+ float4(0.006369, 0.99998, 0.0, 0.577584)
+};
+constant float4 PRISM_BOXES[44] = {
+ float4(0.030135, 0.06127, -0.043945, 0.0),
+ float4(0.330601, 0.483055, 0.116951, 7.0),
+ float4(0.034429, 0.265258, -0.043945, 7.0),
+ float4(0.542104, 0.741798, 0.116951, 8.0),
+ float4(0.034429, 0.355442, -0.043945, 15.0),
+ float4(0.305908, 0.822319, 0.116951, 8.0),
+ float4(0.267405, 0.532589, -0.043945, 23.0),
+ float4(0.542104, 0.822319, 0.082595, 8.0),
+ float4(-0.045345, 0.036855, -0.044263, 31.0),
+ float4(0.045345, 0.23354, 0.059405, 8.0),
+ float4(-0.224888, 0.072548, -0.044263, 39.0),
+ float4(0.059405, 0.565586, 0.059405, 8.0),
+ float4(-0.032366, 0.042263, -0.044263, 47.0),
+ float4(0.148095, 0.408756, 0.059405, 8.0),
+ float4(0.074711, 0.08769, -0.044263, 55.0),
+ float4(0.184869, 0.464999, 0.064813, 8.0),
+ float4(0.146095, 0.271559, -0.044263, 63.0),
+ float4(0.211908, 0.446612, 0.038855, 8.0),
+ float4(0.23803, 0.310496, -0.044263, 71.0),
+ float4(0.320067, 0.460672, 0.06265, 8.0),
+ float4(0.275885, 0.310496, -0.044263, 79.0),
+ float4(0.412001, 0.475815, 0.047508, 8.0),
+ float4(0.375391, 0.350514, -0.044263, 87.0),
+ float4(0.45202, 0.38388, 0.044263, 4.0),
+ float4(0.410001, 0.350514, -0.044263, 91.0),
+ float4(0.474733, 0.401185, 0.044263, 5.0),
+ float4(-0.224888, 0.379717, -0.044263, 96.0),
+ float4(-0.0812, 0.532057, 0.047508, 8.0),
+ float4(-0.095098, 0.228295, -0.044263, 104.0),
+ float4(0.211908, 0.565586, 0.064813, 8.0),
+ float4(-0.222724, 0.430551, -0.044263, 112.0),
+ float4(-0.011979, 0.833818, 0.053998, 8.0),
+ float4(-0.100506, 0.438122, -0.044263, 120.0),
+ float4(0.327638, 0.833818, 0.0832, 8.0),
+ float4(0.127708, 0.312659, -0.044263, 128.0),
+ float4(0.282211, 0.467162, 0.064813, 8.0),
+ float4(0.245601, 0.352677, -0.044263, 136.0),
+ float4(0.474733, 0.604523, 0.06265, 8.0),
+ float4(0.178543, 0.390533, -0.044263, 144.0),
+ float4(0.474733, 0.639133, 0.0832, 8.0),
+ float4(0.200174, 0.483549, -0.044263, 152.0),
+ float4(0.474733, 0.639133, 0.0832, 4.0),
+ float4(-0.013979, 0.487875, -0.044263, 156.0),
+ float4(0.467162, 0.833818, 0.0832, 5.0)
+};
+constant float2 PRISM_POLY[110] = {
+ float2(0.006442, -0.002147),
+ float2(-0.001074, 0.001074),
+ float2(-0.004294, 0.007515),
+ float2(-0.004294, 0.806288),
+ float2(-0.001074, 0.809509),
+ float2(0.004294, 0.809509),
+ float2(0.022546, 0.796626),
+ float2(0.185736, 0.655982),
+ float2(0.281288, 0.86319),
+ float2(0.286656, 0.870706),
+ float2(0.293098, 0.871779),
+ float2(0.465951, 0.781595),
+ float2(0.478834, 0.773006),
+ float2(0.478834, 0.763344),
+ float2(0.407975, 0.592638),
+ float2(0.45092, 0.586196),
+ float2(0.601227, 0.573313),
+ float2(0.606595, 0.569018),
+ float2(0.606595, 0.564724),
+ float2(0.601227, 0.556135),
+ float2(0.50138, 0.453067),
+ float2(0.425153, 0.37684),
+ float2(0.289877, 0.246933),
+ float2(0.081595, 0.057975),
+ float2(0.013957, 0.0),
+ float2(-0.011897, 0.0),
+ float2(-0.055161, 0.037855),
+ float2(-0.081119, 0.063813),
+ float2(-0.083282, 0.06814),
+ float2(-0.084363, 0.38288),
+ float2(-0.089771, 0.38288),
+ float2(-0.158993, 0.33529),
+ float2(-0.166564, 0.336372),
+ float2(-0.250927, 0.379635),
+ float2(-0.261743, 0.388288),
+ float2(-0.263906, 0.461836),
+ float2(-0.262824, 0.486712),
+ float2(-0.173053, 0.638133),
+ float2(-0.044345, 0.860939),
+ float2(-0.036774, 0.867429),
+ float2(-0.024876, 0.871755),
+ float2(0.33529, 0.871755),
+ float2(0.349351, 0.867429),
+ float2(0.363412, 0.853368),
+ float2(0.513752, 0.619747),
+ float2(0.513752, 0.379635),
+ float2(0.466162, 0.322311),
+ float2(0.461836, 0.320148),
+ float2(0.399104, 0.311496),
+ float2(0.393696, 0.313659),
+ float2(0.374227, 0.332046),
+ float2(0.343943, 0.288782),
+ float2(0.336372, 0.282293),
+ float2(0.269314, 0.271477),
+ float2(0.263906, 0.27364),
+ float2(0.243356, 0.292027),
+ float2(0.21199, 0.244438),
+ float2(0.207664, 0.241193),
+ float2(0.149258, 0.231459),
+ float2(0.138443, 0.231459),
+ float2(0.118974, 0.248764),
+ float2(0.116811, 0.222806),
+ float2(0.117892, 0.162237),
+ float2(0.115729, 0.076792),
+ float2(0.06814, 0.011897),
+ float2(0.062732, 0.007571),
+ float2(0.005408, 0.0),
+ float2(0.031135, 0.06227),
+ float2(0.252301, 0.266258),
+ float2(0.541104, 0.544325),
+ float2(0.359663, 0.559356),
+ float2(0.433742, 0.753681),
+ float2(0.304908, 0.821319),
+ float2(0.199693, 0.595859),
+ float2(0.035429, 0.740798),
+ float2(0.035429, 0.482055),
+ float2(-0.043263, 0.077874),
+ float2(0.001082, 0.037855),
+ float2(0.044345, 0.043263),
+ float2(0.075711, 0.08869),
+ float2(0.077874, 0.407756),
+ float2(0.10924, 0.439122),
+ float2(0.111403, 0.307169),
+ float2(0.147095, 0.272559),
+ float2(0.189277, 0.28013),
+ float2(0.210909, 0.313659),
+ float2(0.210909, 0.445612),
+ float2(0.23903, 0.45102),
+ float2(0.241193, 0.343943),
+ float2(0.276885, 0.311496),
+ float2(0.319067, 0.319067),
+ float2(0.34178, 0.353677),
+ float2(0.340698, 0.474815),
+ float2(0.375309, 0.488875),
+ float2(0.376391, 0.381799),
+ float2(0.411001, 0.351514),
+ float2(0.45102, 0.359085),
+ float2(0.473733, 0.391533),
+ float2(0.473733, 0.603523),
+ float2(0.466162, 0.618665),
+ float2(0.326638, 0.830655),
+ float2(-0.012979, 0.832818),
+ float2(-0.099506, 0.68356),
+ float2(-0.221724, 0.475896),
+ float2(-0.223888, 0.407756),
+ float2(-0.163319, 0.380717),
+ float2(-0.094098, 0.431551),
+ float2(-0.0822, 0.531057),
+ float2(-0.045426, 0.564586),
+ float2(-0.044345, 0.23254)
+};
+constant float PRISM_BEVEL = 0.012883;
+// end of the prism mesh
-static float s_gem_arrow(float3 p)
-{
- float z0 = SCULPT_HOVER + 0.03;
- float slab = max(p.z - z0 - 0.2, z0 - 0.03 - p.z);
- float head = max(slab, max(s_gem_edge(p, float2(-0.02, 0.03), float2(-0.02, -0.88), z0),
- max(s_gem_edge(p, float2(-0.02, -0.88), float2(0.66, -0.61), z0),
- s_gem_edge(p, float2(0.66, -0.61), float2(-0.02, 0.03), z0))));
- float tail = max(slab, max(max(s_gem_edge(p, float2(0.215, -0.665), float2(0.37, -1.0), z0),
- s_gem_edge(p, float2(0.37, -1.0), float2(0.53, -0.93), z0)),
- max(s_gem_edge(p, float2(0.53, -0.93), float2(0.38, -0.60), z0),
- s_gem_edge(p, float2(0.38, -0.60), float2(0.215, -0.665), z0))));
- return min(head, tail);
-}
+constant float PRISM_IOR = 1.61;
+constant float PRISM_DISPERSION = 0.035;
+constant float PRISM_GLOW = 0.45;
+constant float PRISM_FOLD = 0.35;
+constant int PRISM_BOUNCES = 6;
+constant float PRISM_MAT = 9.0;
-static float s_facet_capsule(float3 p, float3 a, float3 b, float r, float spin)
+inline int prism_shape(constant Layer &layer)
{
- float3 u = normalize(b - a);
- float3 v = normalize(cross(u, float3(0.0, 0.0, 1.0)));
- float3 w = cross(u, v);
- float3 q = p - b;
- float h = dot(q, u);
- float2 rad = float2(dot(q, v), dot(q, w));
- float d = max(h - r, -dot(p - a, u) - r);
- for (int k = 0; k < 6; k++)
- {
- float an = spin + float(k) * 1.0471976;
- float s = dot(rad, float2(cos(an), sin(an)));
- d = max(d, s - r);
- d = max(d, dot(rad, float2(cos(an + 0.5236), sin(an + 0.5236))) * 0.8660 + h * 0.5 - r);
- d = max(d, s * 0.5 + h * 0.8660 - r);
- }
- return d;
+ int id = sculpt_id(layer);
+ return id == 3 || id == 4 ? id - 3 : -1;
}
-constant float3 SCULPT_FACETS[10] = {
- float3(1.0, 0.0, 0.0), float3(0.0, 1.0, 0.0), float3(0.0, 0.0, 1.0),
- float3(0.7071, 0.7071, 0.0), float3(0.7071, 0.0, 0.7071), float3(0.0, 0.7071, 0.7071),
- float3(0.5774, 0.5774, 0.5774), float3(0.4472, 0.0, 0.8944), float3(0.0, 0.4472, 0.8944),
- float3(0.3015, 0.3015, 0.9045)
-};
-
-static float s_facet_ellipsoid(float3 p, float3 r)
+static float prism_poly_dist(float2 p, int i0, int n)
{
- float3 q = abs(p);
- float d = -1e9;
- for (int i = 0; i < 10; i++)
+ float2 vj = PRISM_POLY[i0 + n - 1];
+ float d = 1e9;
+ float sgn = 1.0;
+ for (int i = 0; i < n; i++)
{
- d = max(d, dot(q, SCULPT_FACETS[i]) - length(r * SCULPT_FACETS[i]));
+ float2 vi = PRISM_POLY[i0 + i];
+ float2 e = vj - vi;
+ float2 w = p - vi;
+ float2 b = w - e * saturate(dot(w, e) / dot(e, e));
+ d = min(d, dot(b, b));
+ bool c0 = p.y >= vi.y;
+ bool c1 = p.y < vj.y;
+ bool c2 = e.x * w.y > e.y * w.x;
+ if ((c0 && c1 && c2) || (!c0 && !c1 && !c2))
+ {
+ sgn = -sgn;
+ }
+ vj = vi;
}
- return d;
+ return sgn * sqrt(d);
}
-static float s_crystal_hand(float3 p)
+static float prism_rim(float3 p, int shape, constant Layer &layer)
{
- float zc = SCULPT_HAND_ZC;
- float d = s_facet_ellipsoid(p - float3(0.185, -0.70, zc), float3(0.3, 0.235, 0.125));
- d = min(d, s_facet_capsule(p, float3(0.0, -0.52, zc), float3(0.0, -0.10, zc), 0.098, 0.3));
- d = min(d, s_facet_capsule(p, float3(0.17, -0.57, zc + 0.012), float3(0.17, -0.41, zc + 0.045), 0.086, 0.1));
- d = min(d, s_facet_capsule(p, float3(0.31, -0.59, zc + 0.01), float3(0.31, -0.45, zc + 0.04), 0.08, 0.5));
- d = min(d, s_facet_capsule(p, float3(0.435, -0.625, zc + 0.005), float3(0.435, -0.52, zc + 0.03), 0.07, 0.2));
- return min(d, s_facet_capsule(p, float3(0.03, -0.77, zc + 0.03), float3(-0.165, -0.60, zc + 0.065), 0.082, 0.7));
+ float half_t = model_thick(layer) * 0.5;
+ float sil = prism_poly_dist(p.xy, PRISM_SIL_START[shape], PRISM_SIL_COUNT[shape]);
+ float2 w = float2(sil + PRISM_BEVEL, abs(p.z + half_t) - (half_t - PRISM_BEVEL));
+ return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - PRISM_BEVEL;
}
-static float2 sculpt_proto(float3 p, int theme, int shape)
+static float prism_tri(float3 ro, float3 rd, int k)
{
- if (theme == 3)
+ float3 v0 = PRISM_TRIS[4 * k].xyz;
+ float3 e1 = PRISM_TRIS[4 * k + 1].xyz;
+ float3 e2 = PRISM_TRIS[4 * k + 2].xyz;
+ float3 pv = cross(rd, e2);
+ float det = dot(e1, pv);
+ if (abs(det) < 1e-10)
{
- return s_voxels(p, shape);
+ return -1.0;
}
- if (theme == 1)
+ float inv = 1.0 / det;
+ float3 sv = ro - v0;
+ float u = dot(sv, pv) * inv;
+ float3 qv = cross(sv, e1);
+ float v = dot(rd, qv) * inv;
+ if (u < -2e-4 || v < -2e-4 || u + v > 1.0004)
{
- return float2(shape == 0 ? s_gem_arrow(p) : s_crystal_hand(p), 8.0);
+ return -1.0;
}
- float2 body;
- float3 star;
- if (shape == 0)
- {
- float h = 0.075, re = 0.03, dome = 0.0;
- if (theme == 2)
- {
- h = 0.07;
- re = 0.06;
- dome = 0.035;
- }
- if (theme == 4)
+ return dot(e2, qv) * inv;
+}
+
+static float prism_trace(float3 ro, float3 rd, int shape, thread int &hit)
+{
+ float3 inv = 1.0 / select(float3(1e-6), rd, abs(rd) > 1e-6);
+ int b0 = PRISM_BOX_START[shape];
+ int nb = PRISM_BOX_COUNT[shape];
+ float best = 1e9;
+ hit = -1;
+ for (int j = b0; j < b0 + nb; j++)
+ {
+ float4 lo = PRISM_BOXES[2 * j];
+ float4 hi = PRISM_BOXES[2 * j + 1];
+ float3 t0 = (lo.xyz - ro) * inv;
+ float3 t1 = (hi.xyz - ro) * inv;
+ float3 tn = min(t0, t1);
+ float3 tf = max(t0, t1);
+ float tmin = max(max(tn.x, tn.y), tn.z);
+ float tmax = min(min(tf.x, tf.y), tf.z);
+ if (tmax < max(tmin, 1e-5) || tmin > best)
{
- h = 0.07;
- re = 0.055;
- dome = 0.025;
+ continue;
}
- body = float2(s_arrow_solid(p, h, re, dome), 1.0);
- if (theme == 0)
+ int k0 = int(lo.w);
+ int n = int(hi.w);
+ for (int i = k0; i < k0 + n; i++)
{
- body = s_opu(body, float2(s_piping(p, SCULPT_HOVER + 2.0 * h - 0.004), 2.0));
+ float t = prism_tri(ro, rd, i);
+ if (t > 1e-5 && t < best)
+ {
+ best = t;
+ hit = i;
+ }
}
- star = float3(0.57, -0.86, SCULPT_HOVER + 2.0 * h + dome);
}
- else
+ return best;
+}
+
+static float2 sculpt_proto(float3 p, int theme, int shape)
+{
+ if (theme == 3)
{
- body = s_opu(float2(s_glove(p, SCULPT_HAND_ZC), 1.0), float2(s_cuff(p, SCULPT_HAND_ZC), 2.0));
- star = float3(0.185, -0.93, SCULPT_HAND_ZC + 0.15);
+ return s_voxels(p, shape);
}
- return theme == 4 ? s_opu(body, s_sprout(p, star)) : body;
+ return s_rimmed(p, theme, shape);
}
inline float3 sculpt_point(float3 q, constant Layer &layer)
@@ -961,17 +1960,21 @@ static float2 sculpt_eval(float3 q, bool occ, constant Layer &layer)
{
int id = sculpt_id(layer) - 1;
int theme = id / 2, shape = id % 2;
+ if (theme == 1)
+ {
+ return float2(prism_rim(q, shape, layer), 8.0);
+ }
float3 p = sculpt_point(q, layer);
float2 r;
if (occ && theme == 3)
{
- float d2 = shape == 0 ? s_arrow2(p.xy) - 1.2 * SCULPT_VOX : s_pixel_hand_dist(p.xy);
+ float d2 = s_pixel_outline(p.xy, shape);
float dz = abs(p.z - (SCULPT_HOVER + 0.07)) - 0.07;
r = float2(length(max(float2(d2, dz), 0.0)) + min(max(d2, dz), 0.0), 7.0);
}
else
{
- r = sculpt_proto(p, occ && theme == 1 ? 2 : theme, shape);
+ r = sculpt_proto(p, theme, shape);
}
return float2(r.x * sculpt_units(layer), r.y);
}
@@ -1076,16 +2079,13 @@ inline SculptMat s_mat(float3 alb, float rough, float spec, float sss, float ref
static float3 s_pixel_colour(float3 p, int shape)
{
- float2 id = floor((p.xy - s_grid_origin(shape)) / SCULPT_VOX);
- if (!s_occ(id + float2(-1.0, 0.0), shape) || !s_occ(id + float2(0.0, -1.0), shape))
- {
- return s_lin(0.52, 0.91, 0.77);
- }
- if (!s_occ(id + float2(1.0, 0.0), shape) || !s_occ(id + float2(0.0, 1.0), shape))
- {
- return s_lin(1.0, 0.78, 0.87);
- }
- return s_lin(1.0, 0.50, 0.71);
+ int2 cell = s_pix_cell(p.xy, shape);
+ int4 g = PIX_GRID[shape];
+ int row = g.z + clamp(cell.y, 0, g.y - 1);
+ if (s_pix_bit(PIX_LINE[row], cell.x)) return s_lin(0.29, 0.12, 0.36);
+ if (s_pix_bit(PIX_HI[row], cell.x)) return s_lin(1.0, 0.78, 0.87);
+ if (s_pix_bit(PIX_SHADE[row], cell.x)) return s_lin(0.87, 0.27, 0.51);
+ return s_lin(1.0, 0.435, 0.66);
}
static SculptMat sculpt_material(float mat, float3 p, int theme, int shape)
@@ -1093,28 +2093,26 @@ static SculptMat sculpt_material(float mat, float3 p, int theme, int shape)
bool primary = mat < 1.5;
if (theme == 0)
{
- if (shape == 0 && primary) return s_mat(s_lin(0.10, 0.10, 0.115), 0.3, 0.2, 0.0, 0.9);
- if (shape == 0) return s_mat(s_lin(0.94, 0.91, 0.84), 0.45, 0.4, 0.2, 0.3);
- if (primary) return s_mat(s_lin(0.95, 0.92, 0.85), 0.55, 0.35, 0.35, 0.25);
- return s_mat(s_lin(0.17, 0.18, 0.22), 0.35, 0.6, 0.0, 0.6);
+ if (mat < 2.5) return s_mat(s_lin(0.95, 0.92, 0.85), 0.3, 0.6, 0.35, 0.25);
+ return s_mat(s_lin(0.1, 0.1, 0.11), 0.55, 0.05, 0.0, 0.1);
}
+ if (theme == 1) return s_mat(s_lin(0.02, 0.05, 0.33), 0.35, 0.5, 0.05, 0.3);
if (theme == 2)
{
- if (shape == 0) return s_mat(s_lin(1.0, 0.40, 0.30), 0.5, 0.45, 0.4, 0.25);
- if (primary) return s_mat(s_lin(1.0, 0.79, 0.16), 0.5, 0.45, 0.4, 0.25);
- return s_mat(s_lin(0.18, 0.20, 0.29), 0.4, 0.5, 0.0, 0.4);
+ if ((primary && shape == 0) || (mat > 2.5 && mat < 3.5 && shape == 1)) return s_mat(s_lin(1.0, 0.40, 0.30), 0.85, 0.08, 0.15, 0.03);
+ if (mat < 3.5) return s_mat(s_lin(1.0, 0.80, 0.10), 0.85, 0.08, 0.15, 0.03);
+ return s_mat(s_lin(0.09, 0.13, 0.45), 0.9, 0.05, 0.05, 0.02);
}
if (theme == 4)
{
- if (primary && shape == 0) return s_mat(s_lin(0.62, 0.91, 0.78), 0.22, 0.8, 0.25, 0.6);
- if (primary) return s_mat(s_lin(0.96, 0.94, 0.88), 0.5, 0.35, 0.35, 0.25);
- if (mat < 2.5) return s_mat(s_lin(0.62, 0.91, 0.78), 0.25, 0.7, 0.25, 0.5);
- if (mat < 3.5) return s_mat(s_lin(1.0, 0.80, 0.20), 0.3, 0.6, 0.3, 0.4);
- if (mat < 4.5) return s_mat(s_lin(0.38, 0.80, 0.55), 0.35, 0.5, 0.35, 0.3);
- return s_mat(s_lin(0.16, 0.12, 0.10), 0.2, 0.8, 0.0, 0.5);
+ if (primary && shape == 0) return s_mat(s_lin(0.68, 0.93, 0.80), 0.25, 0.7, 0.3, 0.3);
+ if (primary) return s_mat(s_lin(0.97, 0.95, 0.90), 0.3, 0.6, 0.35, 0.25);
+ if (mat < 2.5) return s_mat(s_lin(0.52, 0.87, 0.78), 0.25, 0.7, 0.3, 0.3);
+ if (mat < 3.5) return s_mat(s_lin(1.0, 0.75, 0.25), 0.25, 0.7, 0.3, 0.3);
+ if (mat < 4.5) return s_mat(s_lin(0.62, 0.92, 0.72), 0.3, 0.6, 0.3, 0.35);
+ return s_mat(s_lin(0.07, 0.15, 0.33), 0.7, 0.15, 0.05, 0.08);
}
- if (mat < 6.5) return s_mat(s_pixel_colour(p, shape), 0.45, 0.35, 0.15, 0.2);
- return s_mat(s_lin(0.36, 0.18, 0.54), 0.45, 0.35, 0.1, 0.2);
+ return s_mat(s_pixel_colour(p, shape), 0.65, 0.1, 0.12, 0.04);
}
static float3 model_env(float3 d, float rough, float3 l, float3 fill)
@@ -1127,34 +2125,6 @@ static float3 model_env(float3 d, float rough, float3 l, float3 fill)
return col;
}
-static float3 s_gem(float k)
-{
- k = saturate(k) * 3.0;
- float3 a = s_lin(0.20, 0.95, 1.0);
- float3 b = s_lin(0.15, 0.42, 1.0);
- float3 c = s_lin(0.45, 0.25, 0.95);
- float3 d = s_lin(0.88, 0.50, 1.0);
- if (k < 1.0) return mix(a, b, k);
- if (k < 2.0) return mix(b, c, k - 1.0);
- return mix(c, d, k - 2.0);
-}
-
-static float3 model_shade_crystal(float3 n, float3 rd, float3 L, float fall, float3 l, float3 fill)
-{
- float cosi = saturate(dot(-rd, n));
- float F = 0.04 + 0.96 * pow(1.0 - cosi, 5.0);
- float3 t = refract(rd, n, 1.0 / 1.6);
- float k = 0.42 + 0.9 * dot(float2(n.x, -n.y), float2(0.7557, -0.6549))
- + 0.6 * dot(float2(t.x, -t.y), float2(0.6, -0.8));
- float3 body = float3(s_gem(k - 0.08).r, s_gem(k).g, s_gem(k + 0.08).b);
- float3 col = body * (0.1 + 1.8 * fall * pow(max(dot(n, L), 0.0), 2.5));
- col += body * model_env(reflect(t, float3(0.0, 0.0, 1.0)) * float3(1.0, 1.0, -1.0), 0.15, l, fill) * 0.5;
- col += pow(max(dot(reflect(rd, n), L), 0.0), 30.0) * 2.0;
- col += model_env(reflect(rd, n), 0.05, l, fill) * F;
- col += s_lin(0.5, 0.9, 1.0) * pow(1.0 - cosi, 4.0) * 0.6;
- return col;
-}
-
static float3 model_tonemap(float3 c)
{
const float start = 0.76;
@@ -1178,16 +2148,16 @@ static float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, f
texture2d texImg)
{
int id = sculpt_id(layer);
- if (id > 0 && (id - 1) / 2 == 1)
- {
- return model_tonemap(model_shade_crystal(n, rd, L, fall, l, fill));
- }
SculptMat m;
float gloss = 1.0;
if (id > 0)
{
float3 p = sculpt_point(q, layer) - float3(n.x, -n.y, n.z) * 0.01;
m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2);
+ if (id == 3 || id == 4)
+ {
+ gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z));
+ }
}
else
{
@@ -1211,13 +2181,168 @@ static float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, f
return model_tonemap(col);
}
+// ---- Le cristal de Prism Glow ---- (cf. HLSL)
+
+inline float prism_fresnel(float c, float ior)
+{
+ float f0 = (ior - 1.0) / (ior + 1.0);
+ f0 *= f0;
+ return f0 + (1.0 - f0) * pow(1.0 - c, 5.0);
+}
+
+static float prism_primary(float3 ro, float3 rd, thread int &hc, constant Layer &layer)
+{
+ hc = -1;
+ int shape = prism_shape(layer);
+ if (shape < 0)
+ {
+ return 1e9;
+ }
+ float t = prism_trace(ro, rd, shape, hc);
+ if (hc >= 0 && fmod(PRISM_TRIS[4 * hc].w, 2.0) > 0.5 && ro.z + rd.z * t < 0.0)
+ {
+ hc = -1;
+ return 1e9;
+ }
+ return t;
+}
+
+inline float3 prism_normal(int h, float3 rd)
+{
+ float3 n = PRISM_TRIS[4 * h + 3].xyz;
+ return dot(n, rd) > 0.0 ? -n : n;
+}
+
+inline float prism_seg(float3 p, float3 a, float3 b)
+{
+ float3 pa = p - a, ba = b - a;
+ return length(pa - ba * saturate(dot(pa, ba) / dot(ba, ba)));
+}
+
+static float prism_edge_px(float3 p, int h, float px)
+{
+ float4 r0 = PRISM_TRIS[4 * h];
+ float3 a = r0.xyz;
+ float3 b = a + PRISM_TRIS[4 * h + 1].xyz;
+ float3 c = a + PRISM_TRIS[4 * h + 2].xyz;
+ int bits = int(r0.w * 0.5);
+ float d = 1e9;
+ if ((bits & 1) != 0) d = min(d, prism_seg(p, b, c));
+ if ((bits & 2) != 0) d = min(d, prism_seg(p, c, a));
+ if ((bits & 4) != 0) d = min(d, prism_seg(p, a, b));
+ return d / px;
+}
+
+static float3 plane_to_world(float3 v, ModelFrame f)
+{
+ float x = v.x * f.c.z - v.y * f.s.z;
+ float y = v.x * f.s.z + v.y * f.c.z;
+ float z = -x * f.s.y + v.z * f.c.y;
+ return float3(x * f.c.y + v.z * f.s.y, y * f.c.x - z * f.s.x, y * f.s.x + z * f.c.x);
+}
+
+static float3 prism_screen(float3 q, float3 d, float3 nz, float hz, float3 tip, float unit, ModelFrame f,
+ constant Layer &layer, texture2d texFrame)
+{
+ float denom = dot(d, nz);
+ if (denom > -1e-4)
+ {
+ return SCULPT_SCREEN * 0.2;
+ }
+ float3 g = q + d * ((hz - dot(q, nz)) / denom);
+ float3 w = plane_to_world(tip + unit * model_to_plane(g, f), f);
+ float k = 1.0 - w.z / layer.src.z;
+ if (k < 1e-3)
+ {
+ return SCULPT_SCREEN * 0.2;
+ }
+ float2 uv = layer.dst.xy + ((w.xy + layer.mb.zw) / k - layer.src.xy) / layer.quad_px * layer.dst.zw;
+ if (any(uv < 0.0) || any(uv > 1.0))
+ {
+ return SCULPT_SCREEN * 0.2;
+ }
+ return pow(texFrame.sample(samp, uv, level(0.0)).rgb, float3(2.2));
+}
+
+static float3 prism_shade(float3 p, float3 n, float3 rd, int h, float px, float3 l, float3 fill, float3 nz,
+ float hz, float3 tip, float unit, ModelFrame f, constant Layer &layer,
+ texture2d texFrame)
+{
+ int shape = prism_shape(layer);
+ float4 r3 = PRISM_TRIS[4 * h + 3];
+ float3 facet = float3(PRISM_TRIS[4 * h + 1].w, PRISM_TRIS[4 * h + 2].w, r3.w);
+ float F = prism_fresnel(saturate(-dot(rd, n)), PRISM_IOR);
+ float zb = -model_thick(layer);
+ float3 trans = float3(0.0);
+ for (int ch = 0; ch < 3; ch++)
+ {
+ float3 mask = float3(float(ch == 0), float(ch == 1), float(ch == 2));
+ float ior = PRISM_IOR + PRISM_DISPERSION * float(ch - 1);
+ float3 d = refract(rd, n, 1.0 / ior);
+ float3 pos = p;
+ float thr = 1.0;
+ float acc = 0.0;
+ for (int b = 0; b < PRISM_BOUNCES; b++)
+ {
+ int hh;
+ float th = prism_trace(pos, d, shape, hh);
+ float tb = d.z < -1e-6 ? (zb - pos.z) / d.z : 1e9;
+ if (tb <= th)
+ {
+ float3 qb = pos + d * tb;
+ float3 d2 = refract(d, float3(0.0, 0.0, 1.0), ior);
+ if (dot(d2, d2) < 1e-8)
+ {
+ pos = qb;
+ d = reflect(d, float3(0.0, 0.0, 1.0));
+ continue;
+ }
+ acc += thr * dot(prism_screen(qb, d2, nz, hz, tip, unit, f, layer, texFrame), mask);
+ thr = 0.0;
+ break;
+ }
+ if (hh < 0)
+ {
+ break;
+ }
+ pos += d * th;
+ float4 rn = PRISM_TRIS[4 * hh + 3];
+ if (fmod(PRISM_TRIS[4 * hh].w, 2.0) > 0.5 && pos.z < 0.0)
+ {
+ acc += thr * dot(s_lin(0.03, 0.06, 0.37) * 0.35, mask);
+ thr = 0.0;
+ break;
+ }
+ float3 dout = refract(d, -rn.xyz, ior);
+ if (dot(dout, dout) < 1e-8)
+ {
+ d = reflect(d, -rn.xyz);
+ continue;
+ }
+ float fi = prism_fresnel(saturate(dot(dout, rn.xyz)), ior);
+ acc += thr * (1.0 - fi) * dot(model_env(dout, 0.05, l, fill), mask);
+ thr *= fi;
+ d = reflect(d, -rn.xyz);
+ }
+ trans += acc * mask;
+ }
+ float3 col = F * model_env(reflect(rd, n), 0.05, l, fill) + (1.0 - F) * trans + facet * PRISM_GLOW;
+ float fold = 1.0 - smoothstep(0.2, 1.0, prism_edge_px(p, h, px));
+ col = mix(col, s_lin(0.96, 0.99, 1.0) * 1.3, PRISM_FOLD * fold * (0.35 + 0.65 * saturate(dot(n, l))));
+ return model_tonemap(col);
+}
+
// Les passes de la boucle unique de `cursor_model` (cf. HLSL : un seul appel de `model_eval`).
constant int STAGE_MARCH = 0;
constant int STAGE_NORMAL = 1;
constant int STAGE_AO = 2;
constant int STAGE_SELF = 3;
constant int STAGE_PLANE = 4;
-constant int STAGE_DONE = 5;
+constant int STAGE_EDGE = 5;
+constant int STAGE_SHADE = 6;
+constant int STAGE_DONE = 7;
+
+constant float2 MODEL_SUBPIXEL[3] = { float2(0.35, 0.2), float2(-0.35, 0.2), float2(0.0, -0.4) };
inline float3 model_tetra(int k)
{
@@ -1226,7 +2351,8 @@ inline float3 model_tetra(int k)
static float4 cursor_model(float2 local, constant Layer &layer,
texture2d texSdf,
- texture2d texImg)
+ texture2d texImg,
+ texture2d texFrame)
{
ModelFrame f;
f.c = cos(layer.fx.xyz);
@@ -1253,15 +2379,21 @@ static float4 cursor_model(float2 local, constant Layer &layer,
float stride = sculpt_id(layer) > 0 ? 0.85 : 1.0;
float3 lamp = float3(layer.color.rg + sprite_size(layer) * 0.5, 0.0) + l * SCULPT_LAMP_DIST;
- float2 tb = ray_box(ro, rd, lo - 0.02, hi + 0.02);
+ float3 ray = rd;
+ float2 tb = ray_box(ro, ray, lo - 0.02, hi + 0.02);
int stage = tb.x < tb.y && tb.y > 0.0 ? STAGE_MARCH : STAGE_DONE;
bool plane_next = stage == STAGE_DONE;
+ bool need_tc = stage == STAGE_MARCH;
+ float tc = 1e9;
+ int hc = -1;
int k = 0;
float t = max(tb.x, 0.0);
float best = 1e9;
float t_best = t;
+ float d_best = 0.0;
float mat = 0.0;
float cov = 0.0;
+ float cov_s = 0.0;
float3 q = float3(0.0);
float3 n = float3(0.0);
float3 L = float3(0.0);
@@ -1273,12 +2405,20 @@ static float4 cursor_model(float2 local, constant Layer &layer,
float inside = 0.0;
float contact = 0.0;
float dropped = 0.0;
- for (int it = 0; it < 180; it++)
+ int sub = 0;
+ int shaded = 0;
+ float4 acc = float4(0.0);
+ for (int it = 0; it < 500; it++)
{
+ if (need_tc)
+ {
+ need_tc = false;
+ tc = prism_primary(ro, ray, hc, layer);
+ }
if (plane_next)
{
plane_next = false;
- stage = STAGE_DONE;
+ stage = cov > 0.0 ? STAGE_SHADE : STAGE_DONE;
float denom = dot(rd, nz);
if (cov < 1.0 && denom < -1e-4)
{
@@ -1293,18 +2433,64 @@ static float4 cursor_model(float2 local, constant Layer &layer,
}
}
}
+ if (stage == STAGE_SHADE)
+ {
+ if (cov_s > 0.0)
+ {
+ float3 c;
+ if (mat > PRISM_MAT - 0.5)
+ {
+ c = prism_shade(q, n, ray, hc, t_best / dlen, l, fill, nz, hz, tip, unit, f, layer, texFrame);
+ }
+ else
+ {
+ float3 tl = lamp - q;
+ float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl);
+ c = model_shade(q, n, ray, normalize(tl), fall, sh, ao, mat, l, fill, layer, texSdf, texImg);
+ }
+ acc += float4(c * cov_s, cov_s);
+ }
+ shaded++;
+ stage = STAGE_DONE;
+ if (sub > 0)
+ {
+ float3 dws = float3(local + MODEL_SUBPIXEL[3 - sub] + layer.src.xy, -persp);
+ sub--;
+ dlen = length(dws);
+ ray = plane_to_model(world_to_plane(dws / dlen, f), f);
+ tb = ray_box(ro, ray, lo - 0.02, hi + 0.02);
+ cov_s = 0.0;
+ stage = STAGE_SHADE;
+ if (tb.x < tb.y && tb.y > 0.0)
+ {
+ stage = STAGE_MARCH;
+ k = 0;
+ t = max(tb.x, 0.0);
+ best = 1e9;
+ t_best = t;
+ need_tc = true;
+ }
+ }
+ continue;
+ }
float3 pos;
if (stage == STAGE_MARCH)
{
- pos = ro + rd * t;
+ pos = ro + ray * t;
}
else if (stage == STAGE_NORMAL)
{
pos = q + 0.002 * model_tetra(k);
}
+ else if (stage == STAGE_EDGE)
+ {
+ float3 side = normalize(cross(n, ray));
+ float3 e = (k >= 2 ? cross(n, side) : side) * (k % 2 == 1 ? -1.0 : 1.0);
+ pos = q + e * 0.5 * t_best / dlen;
+ }
else if (stage == STAGE_AO)
{
- pos = q + (0.01 + 0.0175 * float(k)) * SCULPT_SCALE * n;
+ pos = q + (0.01 + 0.0175 * k) * SCULPT_SCALE * n;
}
else if (stage == STAGE_SELF)
{
@@ -1328,22 +2514,64 @@ static float4 cursor_model(float2 local, constant Layer &layer,
{
best = hit ? 0.0 : d / fp;
t_best = t;
+ d_best = d;
mat = m.y;
}
t += d * stride;
k++;
+ if (!hit && t >= tc)
+ {
+ hit = true;
+ best = 0.0;
+ t_best = tc;
+ d_best = 0.0;
+ mat = PRISM_MAT;
+ }
if (hit || t > tb.y || k == 96)
{
- cov = saturate(1.0 - best);
- if (cov > 0.0)
+ cov_s = saturate(1.0 - best);
+ if (shaded > 0)
{
- q = ro + rd * t_best;
- stage = STAGE_NORMAL;
- k = 0;
+ stage = STAGE_SHADE;
+ if (cov_s > 0.0)
+ {
+ q = ro + ray * t_best;
+ n = float3(0.0);
+ stage = STAGE_NORMAL;
+ k = 0;
+ }
}
else
{
- plane_next = true;
+ cov = cov_s;
+ if (cov > 0.0)
+ {
+ q = ro + ray * t_best;
+ stage = STAGE_NORMAL;
+ k = 0;
+ }
+ else
+ {
+ plane_next = true;
+ }
+ }
+ if (stage == STAGE_NORMAL && mat > PRISM_MAT - 0.5)
+ {
+ n = prism_normal(hc, ray);
+ k = 0;
+ if (shaded > 0)
+ {
+ stage = STAGE_SHADE;
+ }
+ else
+ {
+ if (prism_edge_px(q, hc, t_best / dlen) < 0.75)
+ {
+ sub = 3;
+ }
+ stage = STAGE_DONE;
+ plane_next = true;
+ }
}
}
}
@@ -1354,6 +2582,31 @@ static float4 cursor_model(float2 local, constant Layer &layer,
if (k == 4)
{
n = normalize(n);
+ k = 0;
+ stage = shaded > 0 ? STAGE_SHADE : STAGE_EDGE;
+ }
+ }
+ else if (stage == STAGE_EDGE)
+ {
+ if (m.y != mat || abs(d - d_best) > 0.02 * t_best / dlen)
+ {
+ sub = 3;
+ }
+ if (k == 0 && mat > 7.5)
+ {
+ int ps = prism_shape(layer);
+ if (ps >= 0)
+ {
+ float dout = prism_poly_dist(q.xy, PRISM_OUT_START[ps], PRISM_OUT_COUNT[ps]);
+ if (abs(dout) < 0.75 * t_best / dlen)
+ {
+ sub = 3;
+ }
+ }
+ }
+ k++;
+ if (k == 4)
+ {
stage = STAGE_AO;
k = 0;
ao = 0.0;
@@ -1361,7 +2614,7 @@ static float4 cursor_model(float2 local, constant Layer &layer,
}
else if (stage == STAGE_AO)
{
- ao += ((0.01 + 0.0175 * float(k)) * SCULPT_SCALE - d) * pow(0.85, float(k));
+ ao += ((0.01 + 0.0175 * k) * SCULPT_SCALE - d) * pow(0.85, float(k));
k++;
if (k == 5)
{
@@ -1402,7 +2655,7 @@ static float4 cursor_model(float2 local, constant Layer &layer,
k = 1;
if (!(tbp.x < tbp.y && tbp.y > 0.0))
{
- stage = STAGE_DONE;
+ stage = cov > 0.0 ? STAGE_SHADE : STAGE_DONE;
}
}
else
@@ -1414,21 +2667,15 @@ static float4 cursor_model(float2 local, constant Layer &layer,
{
res = saturate(res);
dropped = 1.0 - res * res * (3.0 - 2.0 * res);
- stage = STAGE_DONE;
+ stage = cov > 0.0 ? STAGE_SHADE : STAGE_DONE;
}
}
}
- float3 rgb = float3(0.0);
- if (cov > 0.0)
- {
- float3 tl = lamp - q;
- float fall = SCULPT_LAMP_DIST * SCULPT_LAMP_DIST / dot(tl, tl);
- rgb = model_shade(q, n, rd, normalize(tl), fall, sh, ao, mat, l, fill, layer, texSdf, texImg);
- }
+ float w = 1.0 / max(shaded, 1);
float shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA);
- float a = cov * layer.color.a;
- return float4(rgb * a, a + (1.0 - a) * shadow * layer.color.a); // prémultiplié, ombre noire
+ float a = acc.a * w * layer.color.a;
+ return float4(acc.rgb * w * layer.color.a, a + (1.0 - a) * shadow * layer.color.a); // prémultiplié, ombre noire
}
// ============ Impact du clic (mode 16) ============
@@ -2009,22 +3256,16 @@ static float4 device_frame(float2 local, constant Layer &layer)
return float4(rgb * a, a); // prémultiplié
}
-fragment float4 ps_main(VSOut i [[stage_in]],
- constant Layer &layer [[buffer(0)]],
- texture2d texY [[texture(0)]],
- texture2d texUV [[texture(1)]],
- texture2d texImg [[texture(2)]],
- // Masque de segmentation du sujet webcam. Non lie tant qu'aucun
- // masque n'existe : Metal rend alors 0, ce qui est sans effet
- // puisque la branche n'est prise que si layer.fx.z > 0.5.
- texture2d texMask [[texture(3)]],
- // Champ de distance du sprite de curseur (mode 15 seulement), R16F, cf.
- // `cursor_sdf.rs`. Le sprite lui-même est en texture(2), comme aux
- // modes 7 et 13.
- texture2d texSdf [[texture(4)]],
- // Pyramide de profondeur de champ (`tilted_sample`), lue par le mode 8 et
- // par le repli du mode 18, qui garde texture(2) pour son rendu isolé.
- texture2d texDof [[texture(5)]])
+// The body of `ps_main`, which only adds the layer's transparency on top of it. Same resources
+// as `ps_main`, without their bindings (MSL only allows those on an entry point).
+inline float4 ps_layer(VSOut i,
+ constant Layer &layer,
+ texture2d texY,
+ texture2d texUV,
+ texture2d texImg,
+ texture2d texMask,
+ texture2d texSdf,
+ texture2d texDof)
{
// mode 18 : l'écran CADRÉ (ombre, cadre, métrage, appareil) flouté comme UN objet rigide
// (`FrameGeometry::screen_trail`), port 1:1 du HLSL. texImg = son rendu isolé, prémultiplié,
@@ -2082,6 +3323,7 @@ fragment float4 ps_main(VSOut i [[stage_in]],
return acc / max(n, 1.0);
}
+#ifdef LAYER_MODELS
// mode 17 : CADRE D'APPAREIL MODELÉ (`device_frame`). Testé en premier, comme le 16.
if (layer.mode > 16.5)
{
@@ -2103,8 +3345,16 @@ fragment float4 ps_main(VSOut i [[stage_in]],
{
return float4(0.0, 0.0, 0.0, 0.0);
}
- return cursor_model(i.local, layer, texSdf, texImg);
+ return cursor_model(i.local, layer, texSdf, texImg, texDof);
+ }
+#else
+ // Les modèles 3D n'existent que dans la variante `LAYER_MODELS` ; un draw qui les enverrait
+ // ici ne peint rien plutôt qu'une ombre faite de leurs emplacements.
+ if (layer.mode > 14.5)
+ {
+ return float4(0.0, 0.0, 0.0, 0.0);
}
+#endif
// mode 14 : CADRE DE FENÊTRE autour de l'écran, dessiné SOUS lui. Cf. commentaires HLSL.
// Testé avant le mode 13, dont la branche n'a pas de borne haute.
@@ -2248,8 +3498,12 @@ fragment float4 ps_main(VSOut i [[stage_in]],
float2 plane_px = layer.dst_prev.xy;
float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5;
float rad = max(layer.radius_px, 0.0);
- float d = (layer.dst_prev.z > 0.5) ? sd_screen_under_bar(p, plane_px * 0.5, rad, layer.color.z)
- : sd_round_rect(p, plane_px * 0.5, rad);
+ // Une branche et non `?:`, pour ne jamais évaluer les DEUX distances (trois SDF par pixel).
+ float d;
+ if (layer.dst_prev.z > 0.5)
+ d = sd_screen_under_bar(p, plane_px * 0.5, rad, layer.color.z);
+ else
+ d = sd_round_rect(p, plane_px * 0.5, rad);
float tilt_a = 1.0 - smoothstep(0.0, 1.5, d);
// Slot d'un layout en bloc (`color.w` = rayon de ses coins, px ; 0 ailleurs, sans effet) :
// `dst` EST le slot, dont le rect arrondi rogne le plan (`ScreenMask`, cf. HLSL).
@@ -2502,12 +3756,25 @@ fragment float4 ps_main(VSOut i [[stage_in]],
float3 rgb;
// 1 sauf en detourage, ou il porte le masque du sujet. Cf. la branche fx.z plus bas.
float alpha_mask = 1.0;
+ // 0 where a camera homography finds no camera point (`persp_camera`), 1 everywhere else.
+ float persp_keep = 1.0;
if (layer.mode < 0.5)
{
// flou de mouvement par vélocité (§8)
float2 uv_now = i.uv;
float2 localp = (i.pout - layer.dst_prev.xy) / layer.dst_prev.zw;
float2 uv_prev = layer.src_prev.xy + localp * (layer.src_prev.zw - layer.src_prev.xy);
+ if (layer.layer_fx.y > 0.5)
+ {
+ // Camera homography: the texture uv comes from the fragment's place in the quad
+ // (`src` is not used), scaled to the valid part of the decoder texture. The previous
+ // frame's uv is the same map at the quad's previous place.
+ float3 cam = persp_camera(layer, (i.pout - layer.dst.xy) / layer.dst.zw);
+ persp_keep = cam.z;
+ uv_now = cam.xy * layer.fx.xy;
+ float3 cam_prev = persp_camera(layer, localp);
+ uv_prev = (cam_prev.z > 0.5) ? cam_prev.xy * layer.fx.xy : uv_now;
+ }
float2 duv = uv_now - uv_prev;
float mb_scale = saturate(layer.mb.y);
float2 duv_blur = duv * mb_scale;
@@ -2542,33 +3809,68 @@ fragment float4 ps_main(VSOut i [[stage_in]],
}
else if (effect > 1.5)
{
- rgb = mix(blur_webcam_bg(uv_now, layer.fx.w, layer.quad_px, i.local, texY, texUV), rgb, person);
+ rgb = mix(blur_webcam_bg(uv_now, layer.fx.w, layer.quad_px, i.local, layer.fx.xy, texY, texUV), rgb, person);
}
else
{
alpha_mask = person;
}
}
+
+ // Desk-view cover: the camera is being tilted, so the whole picture is blurred and
+ // dimmed (cover.x = strength, cover.y = radius in quad px, cover.z = dim at full cover).
+ if (layer.cover.x > 0.001)
+ {
+ float3 hidden = blur_webcam_radius(uv_now, layer.cover.y, layer.quad_px, i.local, layer.fx.xy,
+ texY, texUV);
+ rgb = mix(rgb, hidden, layer.cover.x) * (1.0 - layer.cover.z * layer.cover.x);
+ }
}
else
{
rgb = layer.color.rgb;
}
- float alpha = layer.color.a * alpha_mask;
+ float alpha = layer.color.a * alpha_mask * persp_keep;
if (layer.radius_px > 0.0)
{
// mb.w = 1 : écran sous le chrome de fenêtre, coins HAUTS carrés et rognés par l'arc du
// cadre (`sd_screen_under_bar`, `mb.z` = la remontée du contour intérieur, px).
float2 halfsz = layer.quad_px * 0.5;
float2 p = i.local - layer.quad_px * 0.5;
- float d = (layer.mb.w > 0.5) ? sd_screen_under_bar(p, halfsz, layer.radius_px, layer.mb.z)
- : sd_round_rect(p, halfsz, layer.radius_px);
+ // Une branche et non `?:`, pour ne jamais évaluer les DEUX distances : trois SDF par pixel
+ // de l'écran au lieu d'une, 0,6 ms par frame 1080p sur une Radeon 610M (mesuré en WGSL).
+ float d;
+ if (layer.mb.w > 0.5)
+ d = sd_screen_under_bar(p, halfsz, layer.radius_px, layer.mb.z);
+ else
+ d = sd_round_rect(p, halfsz, layer.radius_px);
alpha *= 1.0 - smoothstep(0.0, 1.5, d);
}
return float4(rgb * alpha, alpha);
}
+fragment float4 ps_main(VSOut i [[stage_in]],
+ constant Layer &layer [[buffer(0)]],
+ texture2d