Merge branch 'main' into worktree-agent-a9fea98fe9839069d
This commit is contained in:
commit
05b614990b
|
|
@ -1623,6 +1623,7 @@ dependencies = [
|
|||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
"effectcraft-expr",
|
||||
"effectcraft-gpu",
|
||||
"effectcraft-host",
|
||||
"effectcraft-media",
|
||||
"effectcraft-ui-egui",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ once.
|
|||
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export, presets, Motion Sketch, Wiggler, Smoother | Done |
|
||||
| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking (done); GPU versions of the remaining CPU-only effects | Mostly done |
|
||||
| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (Roto Brush and face tracking use classical models; learned models are still to come) |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, offline install ([docs/web.md](docs/web.md)) |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, viewer frames and GPU effects in frame workers with their own WebGPU devices, the disk cache in the Origin Private File System, a storage manager, offline install ([docs/web.md](docs/web.md)) |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
|
||||
|
||||
## How far from full parity
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ serde_json = { workspace = true }
|
|||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
effectcraft-host = { workspace = true }
|
||||
effectcraft-gpu = { workspace = true }
|
||||
effectcraft-media = { workspace = true }
|
||||
effectcraft-export = { workspace = true }
|
||||
effectcraft-expr = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ export async function storeLoadAll() { return backend.loadAll(); }
|
|||
export async function storePut(key, data, modified) { return backend.put(key, data, modified); }
|
||||
export async function storeDelete(key) { return backend.del(key); }
|
||||
|
||||
/// Ask the browser to keep the origin's storage under pressure; resolves with whether it is.
|
||||
export async function storePersist() {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// {usage, quota, persisted, backend}.
|
||||
export async function storeEstimate() {
|
||||
const out = { backend: backend ? backend.name : null };
|
||||
|
|
@ -341,8 +349,10 @@ export function workerStats() {
|
|||
const frameWorkers = [];
|
||||
|
||||
/// Start a frame worker; returns its index. `onMessage(index, json, bytes, error)` receives each
|
||||
/// `frameReply` (JSON, pixels as a Uint8Array) or, if the worker dies, the error.
|
||||
export function frameWorkerStart(base, onMessage) {
|
||||
/// `frameReply` (JSON, pixels as a Uint8Array) or, if the worker dies, the error. `gpu`: the
|
||||
/// worker opens its own WebGPU device (when the browser has WebGPU in workers) and renders
|
||||
/// GPU effects on it.
|
||||
export function frameWorkerStart(base, onMessage, gpu) {
|
||||
const index = frameWorkers.length;
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
worker.onmessage = (e) => {
|
||||
|
|
@ -353,11 +363,105 @@ export function frameWorkerStart(base, onMessage) {
|
|||
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
|
||||
worker.terminate();
|
||||
};
|
||||
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base });
|
||||
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base, frames: true, gpu: !!gpu });
|
||||
frameWorkers.push(worker);
|
||||
return index;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disk cache (OPFS)
|
||||
|
||||
// The disk cache's frames (src/diskcache.rs): one file per frame under
|
||||
// effectcraft-cache/v1/frames/<32 hex>.ecc in the Origin Private File System. Frame workers write
|
||||
// them with synchronous access handles (a temporary file moved into place, so a reader never
|
||||
// sees a partial entry; every entry also ends in a checksum); the page lists, reads and deletes.
|
||||
const CACHE_ROOT = "effectcraft-cache";
|
||||
const CACHE_PATH = [CACHE_ROOT, "v1", "frames"];
|
||||
let cacheDirHandle = null;
|
||||
|
||||
async function cacheDir() {
|
||||
if (cacheDirHandle) return cacheDirHandle;
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System");
|
||||
let d = await navigator.storage.getDirectory();
|
||||
for (const n of CACHE_PATH) d = await d.getDirectoryHandle(n, { create: true });
|
||||
cacheDirHandle = d;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Every cached entry: [{name, size, modified}]. Temporaries older than ten minutes (a writer
|
||||
/// that died) are removed.
|
||||
export async function cacheList() {
|
||||
const dir = await cacheDir();
|
||||
const out = [];
|
||||
const stale = [];
|
||||
for await (const [name, h] of dir.entries()) {
|
||||
if (h.kind !== "file") continue;
|
||||
const f = await h.getFile();
|
||||
if (name.includes(".tmp")) {
|
||||
if (Date.now() - f.lastModified > 600000) stale.push(name);
|
||||
continue;
|
||||
}
|
||||
out.push({ name, size: f.size, modified: f.lastModified });
|
||||
}
|
||||
for (const n of stale) { try { await dir.removeEntry(n); } catch {} }
|
||||
return out;
|
||||
}
|
||||
|
||||
/// An entry's bytes.
|
||||
export async function cacheRead(name) {
|
||||
const dir = await cacheDir();
|
||||
const f = await (await dir.getFileHandle(name)).getFile();
|
||||
return new Uint8Array(await f.arrayBuffer());
|
||||
}
|
||||
|
||||
/// Write an entry (in a worker: a synchronous access handle; elsewhere a writable stream);
|
||||
/// resolves with its size.
|
||||
export async function cacheWrite(name, bytes) {
|
||||
const dir = await cacheDir();
|
||||
const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype;
|
||||
const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name;
|
||||
const fh = await dir.getFileHandle(tmp, { create: true });
|
||||
if (fh.createSyncAccessHandle) {
|
||||
const h = await fh.createSyncAccessHandle();
|
||||
try {
|
||||
h.truncate(0);
|
||||
h.write(bytes, { at: 0 });
|
||||
h.flush();
|
||||
} finally {
|
||||
h.close();
|
||||
}
|
||||
} else {
|
||||
const w = await fh.createWritable();
|
||||
await w.write(bytes);
|
||||
await w.close();
|
||||
}
|
||||
if (canMove) {
|
||||
try { await fh.move(dir, name); } catch (e) { try { await dir.removeEntry(tmp); } catch {} throw e; }
|
||||
}
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
/// Delete entries (names); missing ones are ignored.
|
||||
export async function cacheDelete(names) {
|
||||
const dir = await cacheDir();
|
||||
for (const n of names) { try { await dir.removeEntry(n); } catch {} }
|
||||
}
|
||||
|
||||
/// Delete the whole cache.
|
||||
export async function cacheClear() {
|
||||
cacheDirHandle = null;
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
await root.removeEntry(CACHE_ROOT, { recursive: true });
|
||||
} catch (e) {
|
||||
if (e.name !== "NotFoundError") throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this thread has WebGPU (`navigator.gpu`).
|
||||
export function hasWebGpu() {
|
||||
return !!(self.navigator && self.navigator.gpu);
|
||||
}
|
||||
|
||||
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
|
||||
export function frameWorkerPost(index, msg, transfer) {
|
||||
const w = frameWorkers[index];
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, browse, files, frames, persist, worker};
|
||||
use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
|
@ -104,6 +104,10 @@ impl eframe::App for WebApp {
|
|||
}
|
||||
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
|
||||
api::set_info("frameWorkers", frames::stats());
|
||||
// Settings ▸ Disk ▸ Disk Cache (frames in the Origin Private File System).
|
||||
let d = &self.app.session.prefs.disk;
|
||||
diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota());
|
||||
api::set_info("diskCache", diskcache::stats());
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
|
|
@ -139,6 +143,7 @@ pub fn session() -> Session {
|
|||
config: Some(Arc::new(persist::config())),
|
||||
offload: (!query_flag("noworkers")).then(|| Arc::new(worker::WorkerOffload) as Arc<dyn effectcraft_engine::offload::Offload>),
|
||||
browser: Some(Arc::new(browse::WebBrowser)),
|
||||
storage: Some(Arc::new(storage::WebStorage)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -186,6 +191,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
|||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
audio::install_unlock();
|
||||
browse::restore();
|
||||
diskcache::init();
|
||||
storage::refresh();
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
|
|
@ -210,10 +217,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
|||
}
|
||||
install_hooks(&mut app);
|
||||
// Viewer frames render in frame workers (`?frameworkers=N`, 0 or `?noworkers`:
|
||||
// on the page's thread).
|
||||
// on the page's thread), each on its own WebGPU device where the browser has
|
||||
// WebGPU in workers (`?nogpuworkers`: their CPU).
|
||||
let n = if query_flag("noworkers") { 0 } else { query_value("frameworkers").and_then(|v| v.parse().ok()).unwrap_or(2usize).min(8) };
|
||||
if n > 0 {
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n))));
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
//! The disk cache (Settings ▸ Disk ▸ Disk Cache) in the browser: viewer frames kept in the
|
||||
//! Origin Private File System between visits, with the size limit from the settings and
|
||||
//! least-recently-used eviction.
|
||||
//!
|
||||
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
|
||||
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
|
||||
//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
|
||||
//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
|
||||
//! `FrameReply::Stored`.
|
||||
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
|
||||
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
|
||||
//! `Stored` replies and by reads; eviction deletes the least recently used files.
|
||||
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
|
||||
//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
|
||||
//!
|
||||
//! Only frames are cached on disk in the browser (the desktop also keeps slow layer buffers):
|
||||
//! a layer lookup happens in the middle of a synchronous render, where the browser's file
|
||||
//! system can't be read.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use effectcraft_engine::render::disk_cache::{self, DiskIndex, Kind};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = cacheList)]
|
||||
fn cache_list() -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = cacheRead)]
|
||||
fn cache_read(name: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = cacheWrite)]
|
||||
pub(crate) fn cache_write(name: &str, bytes: js_sys::Uint8Array) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = cacheDelete)]
|
||||
fn cache_delete(names: js_sys::Array) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = cacheClear)]
|
||||
fn cache_clear() -> js_sys::Promise;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
index: DiskIndex,
|
||||
/// The folder was listed (lookups before then miss).
|
||||
loaded: bool,
|
||||
/// The Origin Private File System works here.
|
||||
available: bool,
|
||||
/// Settings ▸ Disk ▸ Enable Disk Cache.
|
||||
enabled: bool,
|
||||
max_bytes: u64,
|
||||
hits: u64,
|
||||
misses: u64,
|
||||
writes: u64,
|
||||
evictions: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ST: RefCell<State> = RefCell::new(State { enabled: true, max_bytes: 1 << 30, ..Default::default() });
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// List the cache folder and build the index (in the background).
|
||||
pub fn init() {
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
match JsFuture::from(cache_list()).await {
|
||||
Ok(list) => {
|
||||
let mut entries: Vec<(f64, u128, u64)> = js_sys::Array::from(&list)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).ok();
|
||||
let key = disk_cache::parse_entry_name(&get("name")?.as_string()?)?;
|
||||
Some((get("modified")?.as_f64().unwrap_or(0.0), key, get("size")?.as_f64().unwrap_or(0.0) as u64))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
for (_, key, bytes) in entries {
|
||||
s.index.insert(Kind::Frame, key, bytes);
|
||||
}
|
||||
s.loaded = true;
|
||||
s.available = true;
|
||||
});
|
||||
evict();
|
||||
}
|
||||
Err(e) => {
|
||||
let e = js_err(e);
|
||||
log::info!("disk cache unavailable: {e}");
|
||||
ST.with(|s| s.borrow_mut().error = Some(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply the settings: on/off and the limit (`max_gb`, at most half the origin's quota when it
|
||||
/// is known).
|
||||
pub fn configure(enabled: bool, max_gb: u32, quota: Option<u64>) {
|
||||
let mut max = (max_gb.max(1) as u64) << 30;
|
||||
if let Some(q) = quota.filter(|q| *q > 0) {
|
||||
max = max.min(q / 2);
|
||||
}
|
||||
let changed = ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let changed = s.enabled != enabled || s.max_bytes != max;
|
||||
s.enabled = enabled;
|
||||
s.max_bytes = max;
|
||||
changed
|
||||
});
|
||||
if changed {
|
||||
evict();
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache is usable: listed, available and enabled.
|
||||
pub fn active() -> bool {
|
||||
ST.with(|s| {
|
||||
let s = s.borrow();
|
||||
s.loaded && s.available && s.enabled
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame of `key` is cached.
|
||||
pub fn contains(key: u128) -> bool {
|
||||
active() && ST.with(|s| s.borrow().index.contains(Kind::Frame, key))
|
||||
}
|
||||
|
||||
/// A lookup missed (counted for the statistics).
|
||||
pub fn note_miss() {
|
||||
ST.with(|s| s.borrow_mut().misses += 1);
|
||||
}
|
||||
|
||||
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
|
||||
/// damaged.
|
||||
pub async fn read(key: u128) -> Option<disk_cache::Frame8> {
|
||||
let name = disk_cache::entry_name(key);
|
||||
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()).map(|b| b.to_vec());
|
||||
let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
|
||||
ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if frame.is_some() {
|
||||
s.hits += 1;
|
||||
s.index.touch(Kind::Frame, key);
|
||||
} else {
|
||||
s.misses += 1;
|
||||
s.index.remove(Kind::Frame, key);
|
||||
}
|
||||
});
|
||||
if frame.is_none() && bytes.is_some() {
|
||||
delete(vec![name]);
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
/// A worker stored the frame of `key` (`bytes` on disk).
|
||||
pub fn stored(key: u128, bytes: u64) {
|
||||
ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.index.insert(Kind::Frame, key, bytes);
|
||||
s.writes += 1;
|
||||
});
|
||||
evict();
|
||||
}
|
||||
|
||||
fn evict() {
|
||||
let victims = ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let max = if s.enabled { s.max_bytes } else { u64::MAX };
|
||||
let v = s.index.evict(max);
|
||||
s.evictions += v.len() as u64;
|
||||
v
|
||||
});
|
||||
if !victims.is_empty() {
|
||||
delete(victims.into_iter().map(|(_, k)| disk_cache::entry_name(k)).collect());
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(names: Vec<String>) {
|
||||
let arr = js_sys::Array::new();
|
||||
for n in names {
|
||||
arr.push(&JsValue::from_str(&n));
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = JsFuture::from(cache_delete(arr)).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete every cached frame; returns (entries, bytes) removed.
|
||||
pub fn clear() -> (u64, u64) {
|
||||
let r = ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let r = (s.index.len() as u64, s.index.total());
|
||||
s.index.clear();
|
||||
r
|
||||
});
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
if let Err(e) = JsFuture::from(cache_clear()).await {
|
||||
log::warn!("disk cache: clearing failed: {}", js_err(e));
|
||||
}
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
/// `cache.diskStats` / `storage.info().diskCache`.
|
||||
pub fn stats() -> Value {
|
||||
ST.with(|s| {
|
||||
let s = s.borrow();
|
||||
json!({
|
||||
"enabled": s.enabled && s.available,
|
||||
"available": s.available,
|
||||
"loaded": s.loaded,
|
||||
"folder": "Origin Private File System: effectcraft-cache/v1/frames",
|
||||
"entries": s.index.len(),
|
||||
"frames": s.index.len(),
|
||||
"layers": 0,
|
||||
"bytes": s.index.total(),
|
||||
"maxBytes": s.max_bytes,
|
||||
"hits": s.hits,
|
||||
"misses": s.misses,
|
||||
"writes": s.writes,
|
||||
"evictions": s.evictions,
|
||||
"error": s.error,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -6,27 +6,39 @@
|
|||
//! time, then diffs: [`Mirror`]); the frame comes back as premultiplied RGBA8 in a transferred
|
||||
//! buffer. One frame per worker at a time, so the page always decides what renders next (the
|
||||
//! viewer's frame before prefetch). A worker that fails is dropped; with none left, frames render
|
||||
//! on the page's thread again (`Frames::pump`).
|
||||
//! on the page's thread again (`Frames::pump`). With the disk cache on, a frame whose key is
|
||||
//! cached is read from the Origin Private File System instead ([`crate::diskcache`]); the
|
||||
//! others are stored there by the worker that renders them.
|
||||
//!
|
||||
//! **Worker side** — [`worker_frame`]: the worker's [`FrameServer`] (footage from the worker's
|
||||
//! media pool, expressions) handles each message and posts its replies.
|
||||
//! **Worker side** — [`worker_frame_init`] / [`worker_frame`]: the worker's [`FrameServer`]
|
||||
//! (footage from the worker's media pool, expressions) handles each message and posts its
|
||||
//! replies. Where the browser has WebGPU in workers, the worker opens its own device
|
||||
//! ([`Gpu::request_deferred`]) and renders GPU effects and the GPU compositor on it: a frame
|
||||
//! takes several passes, awaiting the device's readbacks in between (they can't be waited for
|
||||
//! synchronously, even in a worker), and Backend Auto picks CPU or GPU per comp from measured
|
||||
//! frame times. Without WebGPU, frames render on the worker's CPU.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::offload::footage_files;
|
||||
use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror};
|
||||
use effectcraft_gpu::Gpu;
|
||||
use effectcraft_ui_egui::frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = frameWorkerStart)]
|
||||
fn frame_worker_start(base: &str, on_message: &js_sys::Function) -> u32;
|
||||
fn frame_worker_start(base: &str, on_message: &js_sys::Function, gpu: bool) -> u32;
|
||||
#[wasm_bindgen(js_name = frameWorkerPost)]
|
||||
fn frame_worker_post(index: u32, msg: &JsValue, transfer: &js_sys::Array);
|
||||
#[wasm_bindgen(js_name = hasWebGpu)]
|
||||
fn has_web_gpu() -> bool;
|
||||
}
|
||||
|
||||
/// One frame worker as the page sees it.
|
||||
|
|
@ -36,6 +48,8 @@ struct Fw {
|
|||
files: HashSet<String>,
|
||||
busy: Option<(u64, RemoteDone)>,
|
||||
alive: bool,
|
||||
/// Its GPU adapter (after it reported), or why it has none.
|
||||
gpu: Option<Result<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -48,6 +62,11 @@ struct State {
|
|||
/// Bytes of project sync messages sent (diffs keep this small).
|
||||
sync_bytes: u64,
|
||||
syncs: u64,
|
||||
/// Frames rendered through a worker's GPU, and the passes of the last one.
|
||||
gpu_frames: u64,
|
||||
last_passes: u32,
|
||||
/// Frames served from the disk cache.
|
||||
disk_hits: u64,
|
||||
}
|
||||
|
||||
type OnMessage = Closure<dyn FnMut(u32, JsValue, JsValue, JsValue)>;
|
||||
|
|
@ -57,6 +76,8 @@ thread_local! {
|
|||
static HANDLER: RefCell<Option<OnMessage>> = const { RefCell::new(None) };
|
||||
/// The worker's frame server (worker side).
|
||||
static SERVER: RefCell<Option<FrameServer>> = const { RefCell::new(None) };
|
||||
/// The worker's GPU device (worker side).
|
||||
static GPU: RefCell<Option<Gpu>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
|
||||
|
|
@ -71,31 +92,22 @@ fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
|
|||
pub struct WebFrames;
|
||||
|
||||
impl WebFrames {
|
||||
/// Start `n` frame workers.
|
||||
pub fn start(n: usize) -> WebFrames {
|
||||
/// Start `n` frame workers; `gpu`: each opens its own WebGPU device where it can.
|
||||
pub fn start(n: usize, gpu: bool) -> WebFrames {
|
||||
let on: OnMessage = Closure::new(|index: u32, json: JsValue, bytes: JsValue, error: JsValue| on_message(index, json, bytes, error));
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
for _ in 0..n {
|
||||
let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref());
|
||||
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true });
|
||||
let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref(), gpu);
|
||||
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true, gpu: None });
|
||||
}
|
||||
});
|
||||
HANDLER.with(|h| *h.borrow_mut() = Some(on));
|
||||
WebFrames
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteFrames for WebFrames {
|
||||
fn slots(&self) -> usize {
|
||||
STATE.with(|s| s.borrow().workers.iter().filter(|w| w.alive).count())
|
||||
}
|
||||
|
||||
fn purge(&self) {
|
||||
purge();
|
||||
}
|
||||
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone) {
|
||||
/// Send `job` to an idle worker.
|
||||
fn render(&self, job: RemoteJob, done: RemoteDone) {
|
||||
let r = STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.next_id += 1;
|
||||
|
|
@ -121,7 +133,8 @@ impl RemoteFrames for WebFrames {
|
|||
sent = t.len() as u64;
|
||||
frame_worker_post(index, &obj(&[("type", "frame".into()), ("json", t.into())]), &js_sys::Array::new());
|
||||
}
|
||||
let req = FrameMsg::Render { id, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts) };
|
||||
let disk = job.disk_key.map(|k| format!("{k:032x}"));
|
||||
let req = FrameMsg::Render { id, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts), disk };
|
||||
frame_worker_post(
|
||||
index,
|
||||
&obj(&[("type", "frame".into()), ("json", serde_json::to_string(&req).unwrap_or_default().into())]),
|
||||
|
|
@ -140,6 +153,49 @@ impl RemoteFrames for WebFrames {
|
|||
}
|
||||
}
|
||||
|
||||
impl RemoteFrames for WebFrames {
|
||||
fn slots(&self) -> usize {
|
||||
STATE.with(|s| s.borrow().workers.iter().filter(|w| w.alive).count())
|
||||
}
|
||||
|
||||
fn purge(&self) {
|
||||
purge();
|
||||
}
|
||||
|
||||
fn gpu(&self) -> bool {
|
||||
STATE.with(|s| s.borrow().workers.iter().any(|w| w.alive && matches!(w.gpu, Some(Ok(_)))))
|
||||
}
|
||||
|
||||
fn disk(&self) -> bool {
|
||||
crate::diskcache::active()
|
||||
}
|
||||
|
||||
fn disk_contains(&self, key: u128) -> bool {
|
||||
crate::diskcache::contains(key)
|
||||
}
|
||||
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone) {
|
||||
// A frame in the disk cache is read, not rendered (a failed read renders it).
|
||||
if let Some(k) = job.disk_key {
|
||||
if crate::diskcache::contains(k) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match crate::diskcache::read(k).await {
|
||||
Some(f) => {
|
||||
STATE.with(|s| s.borrow_mut().disk_hits += 1);
|
||||
done(Ok(RemoteFrame { width: f.width, height: f.height, rgba: f.rgba, ms: 0.0 }));
|
||||
}
|
||||
None => WebFrames.render(job, done),
|
||||
}
|
||||
crate::repaint();
|
||||
});
|
||||
return;
|
||||
}
|
||||
crate::diskcache::note_miss();
|
||||
}
|
||||
self.render(job, done);
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from frame worker `index`: a JSON [`FrameReply`] (with the pixels in `bytes`), or
|
||||
/// `error` when the worker died.
|
||||
fn on_message(index: u32, json: JsValue, bytes: JsValue, error: JsValue) {
|
||||
|
|
@ -154,11 +210,15 @@ fn on_message(index: u32, json: JsValue, bytes: JsValue, error: JsValue) {
|
|||
}
|
||||
let reply: FrameReply = serde_json::from_str(&json.as_string()?).ok()?;
|
||||
match reply {
|
||||
FrameReply::Frame { id, width, height, ms } => {
|
||||
FrameReply::Frame { id, width, height, ms, passes, gpu } => {
|
||||
let (_, d) = w.busy.take_if(|b| b.0 == id)?;
|
||||
let rgba = bytes.dyn_into::<js_sys::Uint8Array>().map(|b| b.to_vec()).unwrap_or_default();
|
||||
st.rendered += 1;
|
||||
st.last_ms = ms;
|
||||
if gpu {
|
||||
st.gpu_frames += 1;
|
||||
st.last_passes = passes;
|
||||
}
|
||||
Some((d, Ok(RemoteFrame { width, height, rgba, ms })))
|
||||
}
|
||||
FrameReply::Failed { id, error, .. } => {
|
||||
|
|
@ -171,6 +231,20 @@ fn on_message(index: u32, json: JsValue, bytes: JsValue, error: JsValue) {
|
|||
w.mirror.reset();
|
||||
None
|
||||
}
|
||||
FrameReply::Stored { key, bytes } => {
|
||||
if let Ok(k) = u128::from_str_radix(&key, 16) {
|
||||
crate::diskcache::stored(k, bytes);
|
||||
}
|
||||
None
|
||||
}
|
||||
FrameReply::Info { gpu, error } => {
|
||||
match (&gpu, &error) {
|
||||
(Some(g), _) => log::info!("frame worker {index}: GPU {g}"),
|
||||
(None, e) => log::info!("frame worker {index}: CPU only ({})", e.as_deref().unwrap_or("no GPU")),
|
||||
}
|
||||
w.gpu = Some(gpu.ok_or_else(|| error.unwrap_or_default()));
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((d, r)) = done {
|
||||
|
|
@ -207,6 +281,15 @@ pub fn purge() {
|
|||
pub fn stats() -> Value {
|
||||
STATE.with(|s| {
|
||||
let s = s.borrow();
|
||||
let gpu: Vec<Value> = s
|
||||
.workers
|
||||
.iter()
|
||||
.map(|w| match &w.gpu {
|
||||
Some(Ok(name)) => json!(name),
|
||||
Some(Err(e)) => json!({"cpu": e}),
|
||||
None => Value::Null,
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"workers": s.workers.len(),
|
||||
"alive": s.workers.iter().filter(|w| w.alive).count(),
|
||||
|
|
@ -216,16 +299,150 @@ pub fn stats() -> Value {
|
|||
"lastMs": s.last_ms,
|
||||
"syncs": s.syncs,
|
||||
"syncBytes": s.sync_bytes,
|
||||
"gpu": gpu,
|
||||
"gpuFrames": s.gpu_frames,
|
||||
"lastPasses": s.last_passes,
|
||||
"diskHits": s.disk_hits,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
|
||||
#[allow(clippy::type_complexity)]
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64)>> = RefCell::new(Default::default());
|
||||
}
|
||||
|
||||
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
|
||||
/// a frame worker (backend `gpu` by default: the worker's WebGPU device) and on the page's CPU
|
||||
/// with the same render options, and compare them. Resolves with `{width, height, maxDiff,
|
||||
/// meanDiff, over4, workerMs, gpuFrames}` (differences in 8-bit levels; `over4`: the fraction
|
||||
/// of channels more than 4 levels apart). Rejects when no worker is idle (try again).
|
||||
#[wasm_bindgen(js_name = workerFrameCheck)]
|
||||
pub fn worker_frame_check(params: JsValue) -> js_sys::Promise {
|
||||
let get = |k: &str| js_sys::Reflect::get(¶ms, &k.into()).ok().filter(|v| !v.is_undefined());
|
||||
let time = get("time").and_then(|v| v.as_f64());
|
||||
let scale = get("scale").and_then(|v| v.as_f64()).unwrap_or(0.5);
|
||||
let backend = get("backend").and_then(|v| v.as_string()).unwrap_or_else(|| "gpu".into());
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let backend = backend.clone();
|
||||
crate::post(move |app| {
|
||||
let s = &app.session;
|
||||
let Some(comp) = s.active_comp_id() else {
|
||||
let _ = reject.call1(&JsValue::NULL, &"no active composition".into());
|
||||
return;
|
||||
};
|
||||
let t = time.map(effectcraft_engine::time::Tick::from_seconds_f64).unwrap_or_else(|| s.time());
|
||||
let mut opts = app.frame_opts(comp, scale);
|
||||
opts.backend = match backend.as_str() {
|
||||
"cpu" => effectcraft_engine::render::Backend::Cpu,
|
||||
"auto" => effectcraft_engine::render::Backend::Auto,
|
||||
_ => effectcraft_engine::render::Backend::Gpu,
|
||||
};
|
||||
let local = {
|
||||
let mut r = effectcraft_engine::render::Renderer::new(
|
||||
&s.project,
|
||||
s.footage.as_ref(),
|
||||
effectcraft_engine::render::RenderOpts { backend: effectcraft_engine::render::Backend::Cpu, ..opts },
|
||||
);
|
||||
r.expr = s.expr.as_deref();
|
||||
let img = r.comp_frame(comp, t);
|
||||
(img.width, img.height, effectcraft_engine::remote::rgba8_premultiplied(&img))
|
||||
};
|
||||
let id = STATE.with(|st| {
|
||||
let mut st = st.borrow_mut();
|
||||
st.next_id += 1;
|
||||
st.next_id
|
||||
});
|
||||
CHECKS.with(|c| c.borrow_mut().insert(id, (resolve.clone(), reject.clone(), local, js_sys::Date::now())));
|
||||
let job = RemoteJob { project: s.project.clone(), revision: s.revision, comp, t, opts, disk_key: None };
|
||||
WebFrames.render(
|
||||
job,
|
||||
Box::new(move |r: Result<RemoteFrame, String>| {
|
||||
let Some((resolve, reject, (w, h, want), t0)) = CHECKS.with(|c| c.borrow_mut().remove(&id)) else { return };
|
||||
match r {
|
||||
Ok(f) if (f.width, f.height) == (w, h) && f.rgba.len() == want.len() => {
|
||||
let (mut max, mut sum, mut over) = (0u32, 0u64, 0u64);
|
||||
for (a, b) in f.rgba.iter().zip(&want) {
|
||||
let d = (*a as i32 - *b as i32).unsigned_abs();
|
||||
max = max.max(d);
|
||||
sum += d as u64;
|
||||
over += (d > 4) as u64;
|
||||
}
|
||||
let n = want.len().max(1) as f64;
|
||||
let c = ((h / 2 * w + w / 2) * 4) as usize;
|
||||
let v = json!({"width": w, "height": h, "maxDiff": max, "meanDiff": sum as f64 / n, "over4": over as f64 / n,
|
||||
"workerMs": f.ms, "wallMs": js_sys::Date::now() - t0, "gpuFrames": stats()["gpuFrames"],
|
||||
"center": [&f.rgba[c..c + 4], &want[c..c + 4]]});
|
||||
let _ = resolve.call1(&JsValue::NULL, &JsValue::from_str(&v.to_string()));
|
||||
}
|
||||
Ok(f) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &format!("size {}x{} vs {w}x{h}", f.width, f.height).into());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &e.into());
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
/// Handle one frame message (a JSON [`FrameMsg`]) in a frame worker; replies are posted with
|
||||
/// their pixels transferred.
|
||||
fn post_reply(r: &FrameReply, px: Option<&[u8]>) {
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
let t = serde_json::to_string(r).unwrap_or_default();
|
||||
let res = match px {
|
||||
Some(px) => {
|
||||
let buf = js_sys::Uint8Array::from(px).buffer();
|
||||
scope.post_message_with_transfer(
|
||||
&obj(&[("type", "frameReply".into()), ("json", t.into()), ("bytes", buf.clone().into())]),
|
||||
&js_sys::Array::of1(&buf),
|
||||
)
|
||||
}
|
||||
None => scope.post_message(&obj(&[("type", "frameReply".into()), ("json", t.into())])),
|
||||
};
|
||||
if let Err(e) = res {
|
||||
log::warn!("frame worker: postMessage: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn new_server() -> FrameServer {
|
||||
FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions)))
|
||||
}
|
||||
|
||||
/// Set up a frame worker (called once by `web/worker.js` after `workerInit`): its frame
|
||||
/// server and, with `gpu` and WebGPU in workers, its own GPU device. Reports
|
||||
/// [`FrameReply::Info`].
|
||||
#[wasm_bindgen(js_name = workerFrameInit)]
|
||||
pub async fn worker_frame_init(gpu: bool) {
|
||||
let mut server = new_server();
|
||||
let info = if !gpu {
|
||||
FrameReply::Info { gpu: None, error: Some("GPU frame workers are off (?nogpuworkers)".into()) }
|
||||
} else if !has_web_gpu() {
|
||||
FrameReply::Info { gpu: None, error: Some("no WebGPU in workers".into()) }
|
||||
} else {
|
||||
match Gpu::request_deferred().await {
|
||||
Ok(g) => {
|
||||
let name = effectcraft_engine::render::Accelerator::name(&g);
|
||||
server.set_accel(Some(Arc::new(g.clone())));
|
||||
GPU.with(|c| *c.borrow_mut() = Some(g));
|
||||
FrameReply::Info { gpu: Some(name), error: None }
|
||||
}
|
||||
Err(e) => FrameReply::Info { gpu: None, error: Some(e) },
|
||||
}
|
||||
};
|
||||
SERVER.with(|c| *c.borrow_mut() = Some(server));
|
||||
post_reply(&info, None);
|
||||
}
|
||||
|
||||
/// Handle one frame message (a JSON [`FrameMsg`]) in a frame worker: replies are posted with
|
||||
/// their pixels transferred. A frame rendering in passes awaits the GPU between them; a frame
|
||||
/// with a disk-cache key is then stored ([`FrameReply::Stored`]).
|
||||
#[wasm_bindgen(js_name = workerFrame)]
|
||||
pub fn worker_frame(json: String) {
|
||||
pub async fn worker_frame(json: String) {
|
||||
let msg: FrameMsg = match serde_json::from_str(&json) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
|
|
@ -233,29 +450,25 @@ pub fn worker_frame(json: String) {
|
|||
return;
|
||||
}
|
||||
};
|
||||
let replies = SERVER.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let server = c.get_or_insert_with(|| {
|
||||
let pool = crate::worker::pool();
|
||||
FrameServer::new(pool, Some(std::sync::Arc::new(effectcraft_expr::Expressions)))
|
||||
});
|
||||
server.handle(msg)
|
||||
});
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
let disk = match &msg {
|
||||
FrameMsg::Render { disk, .. } => disk.clone(),
|
||||
_ => None,
|
||||
};
|
||||
let mut replies = SERVER.with(|c| c.borrow_mut().get_or_insert_with(new_server).handle(msg));
|
||||
while SERVER.with(|c| c.borrow().as_ref().is_some_and(|s| s.waiting())) {
|
||||
let Some(g) = GPU.with(|c| c.borrow().clone()) else { break };
|
||||
g.settled().await;
|
||||
replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default());
|
||||
}
|
||||
for (r, px) in replies {
|
||||
let t = serde_json::to_string(&r).unwrap_or_default();
|
||||
let res = match px {
|
||||
Some(px) => {
|
||||
let buf = js_sys::Uint8Array::from(&px[..]).buffer();
|
||||
scope.post_message_with_transfer(
|
||||
&obj(&[("type", "frameReply".into()), ("json", t.into()), ("bytes", buf.clone().into())]),
|
||||
&js_sys::Array::of1(&buf),
|
||||
)
|
||||
post_reply(&r, px.as_deref());
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) {
|
||||
let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px);
|
||||
let name = format!("{key}.ecc");
|
||||
match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await {
|
||||
Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64 }, None),
|
||||
Err(e) => log::warn!("frame worker: disk cache write failed: {e:?}"),
|
||||
}
|
||||
None => scope.post_message(&obj(&[("type", "frameReply".into()), ("json", t.into())])),
|
||||
};
|
||||
if let Err(e) = res {
|
||||
log::warn!("frame worker: postMessage: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
|
||||
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) |
|
||||
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs ([`frames`]); `Frames::pump` on the UI thread without workers |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers |
|
||||
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) |
|
||||
//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
|
||||
|
|
@ -29,12 +31,16 @@ pub mod audio;
|
|||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod browse;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod diskcache;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod frames;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod persist;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod storage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod worker;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//! The storage manager ([`StorageHost`], `storage.*` commands, Settings ▸ Disk ▸ Browser
|
||||
//! Storage): the origin's usage and quota (`navigator.storage.estimate()`), persistent storage
|
||||
//! (`navigator.storage.persist()`), what the store holds by kind ([`crate::store::FileKind`])
|
||||
//! and the disk cache ([`crate::diskcache`]); clearing each.
|
||||
//!
|
||||
//! The browser answers asynchronously: [`StorageHost::info`] returns the latest estimate and
|
||||
//! asks for a new one at most once a second.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use effectcraft_engine::storage::StorageHost;
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::store::FileKind;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = storePersist)]
|
||||
fn store_persist() -> js_sys::Promise;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Estimate {
|
||||
usage: u64,
|
||||
quota: u64,
|
||||
persisted: bool,
|
||||
/// When the last refresh started (ms).
|
||||
asked: f64,
|
||||
busy: bool,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static EST: RefCell<Estimate> = RefCell::new(Estimate::default());
|
||||
}
|
||||
|
||||
/// Refresh the estimate in the background (at most once a second).
|
||||
pub fn refresh() {
|
||||
let now = js_sys::Date::now();
|
||||
let go = EST.with(|e| {
|
||||
let mut e = e.borrow_mut();
|
||||
if e.busy || now - e.asked < 1000.0 {
|
||||
return false;
|
||||
}
|
||||
e.busy = true;
|
||||
e.asked = now;
|
||||
true
|
||||
});
|
||||
if !go {
|
||||
return;
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let v = JsFuture::from(crate::persist::store_estimate()).await.ok();
|
||||
EST.with(|e| {
|
||||
let mut e = e.borrow_mut();
|
||||
e.busy = false;
|
||||
if let Some(v) = v {
|
||||
let get = |k: &str| js_sys::Reflect::get(&v, &k.into()).ok();
|
||||
e.usage = get("usage").and_then(|x| x.as_f64()).unwrap_or(0.0) as u64;
|
||||
e.quota = get("quota").and_then(|x| x.as_f64()).unwrap_or(0.0) as u64;
|
||||
e.persisted = get("persisted").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The origin's quota in bytes, when known.
|
||||
pub fn quota() -> Option<u64> {
|
||||
EST.with(|e| Some(e.borrow().quota).filter(|q| *q > 0))
|
||||
}
|
||||
|
||||
/// The web app's [`StorageHost`].
|
||||
pub struct WebStorage;
|
||||
|
||||
impl StorageHost for WebStorage {
|
||||
fn info(&self) -> Value {
|
||||
refresh();
|
||||
let (usage, quota, persisted) = EST.with(|e| {
|
||||
let e = e.borrow();
|
||||
(e.usage, e.quota, e.persisted)
|
||||
});
|
||||
json!({
|
||||
"backend": crate::persist::backend(),
|
||||
"usage": usage,
|
||||
"quota": quota,
|
||||
"persisted": persisted,
|
||||
"files": crate::files::STORE.usage(),
|
||||
"diskCache": crate::diskcache::stats(),
|
||||
"error": crate::persist::last_error(),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_persist(&self) -> Value {
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let granted = JsFuture::from(store_persist()).await.ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
EST.with(|e| e.borrow_mut().persisted = granted);
|
||||
crate::repaint();
|
||||
});
|
||||
json!({"requested": true, "persisted": EST.with(|e| e.borrow().persisted)})
|
||||
}
|
||||
|
||||
fn clear(&self, what: &str) -> Result<Value, String> {
|
||||
let kinds: Vec<FileKind> = match what {
|
||||
"diskCache" => vec![],
|
||||
"all" => vec![FileKind::Media, FileKind::Project, FileKind::AutoSave],
|
||||
k => vec![FileKind::from_name(k).ok_or_else(|| format!("unknown storage kind {k}"))?],
|
||||
};
|
||||
let (mut entries, mut bytes) = (0u64, 0u64);
|
||||
if matches!(what, "diskCache" | "all") {
|
||||
let (n, b) = crate::diskcache::clear();
|
||||
entries += n;
|
||||
bytes += b;
|
||||
}
|
||||
for k in kinds {
|
||||
let (n, b) = crate::files::STORE.clear_kind(k);
|
||||
entries += n;
|
||||
bytes += b;
|
||||
}
|
||||
// The estimate catches up on the next refresh.
|
||||
EST.with(|e| e.borrow_mut().asked = 0.0);
|
||||
Ok(json!({"cleared": what, "entries": entries, "bytes": bytes}))
|
||||
}
|
||||
}
|
||||
|
|
@ -182,6 +182,74 @@ impl Store {
|
|||
pub fn files(&self) -> Vec<(String, usize, f64, bool)> {
|
||||
self.lock().list(FILES).into_iter().filter_map(|(k, n, m, p)| Some((key_path(&k)?.to_string(), n, m, p))).collect()
|
||||
}
|
||||
|
||||
/// Stored (persisted) files by kind: `{projects, media, autoSaves}`, each `{count, bytes}`
|
||||
/// (the storage manager, `storage.info`).
|
||||
pub fn usage(&self) -> serde_json::Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for k in [FileKind::Project, FileKind::Media, FileKind::AutoSave] {
|
||||
let (mut count, mut bytes) = (0u64, 0u64);
|
||||
for (path, n, _, persisted) in self.files() {
|
||||
if persisted && FileKind::of(&path) == k {
|
||||
count += 1;
|
||||
bytes += n as u64;
|
||||
}
|
||||
}
|
||||
out.insert(k.name().into(), serde_json::json!({"count": count, "bytes": bytes}));
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
/// Delete every stored file of a kind; returns (files, bytes) removed.
|
||||
pub fn clear_kind(&self, kind: FileKind) -> (u64, u64) {
|
||||
let (mut count, mut bytes) = (0u64, 0u64);
|
||||
for (path, n, _, persisted) in self.files() {
|
||||
if persisted && FileKind::of(&path) == kind {
|
||||
self.lock().remove(&file_key(&path));
|
||||
count += 1;
|
||||
bytes += n as u64;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
self.changed();
|
||||
}
|
||||
(count, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a stored file is (Settings ▸ Disk ▸ Browser Storage).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
/// A saved project (`.ecproj` outside the auto-save folder).
|
||||
Project,
|
||||
/// Imported footage and everything else.
|
||||
Media,
|
||||
/// Auto-saves (`/EffectCraft Auto-Save/…`, or `… auto-save N.ecproj`).
|
||||
AutoSave,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
pub fn of(path: &str) -> FileKind {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
if !lower.ends_with(".ecproj") {
|
||||
return FileKind::Media;
|
||||
}
|
||||
let name = lower.rsplit('/').next().unwrap_or(&lower);
|
||||
if lower.contains("/effectcraft auto-save/") || name.contains(" auto-save ") { FileKind::AutoSave } else { FileKind::Project }
|
||||
}
|
||||
|
||||
/// `storage.clear` / `storage.info` name.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FileKind::Project => "projects",
|
||||
FileKind::Media => "media",
|
||||
FileKind::AutoSave => "autoSaves",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(n: &str) -> Option<FileKind> {
|
||||
[FileKind::Project, FileKind::Media, FileKind::AutoSave].into_iter().find(|k| k.name() == n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings and friends ([`ConfigStore`]) in the store; auto-saves ([`FileOps`]) in its file
|
||||
|
|
@ -289,6 +357,28 @@ mod tests {
|
|||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_by_kind_and_clearing() {
|
||||
let s = store();
|
||||
s.put_file("/a.ecproj", Arc::from(&b"proj"[..]), true);
|
||||
s.put_file("/clip.mp4", Arc::from(&b"0123456789"[..]), true);
|
||||
s.put_file("/still.png", Arc::from(&b"px"[..]), true);
|
||||
s.put_file("/EffectCraft Auto-Save/a auto-save 1.ecproj", Arc::from(&b"auto"[..]), true);
|
||||
s.put_file("/render.gif", Arc::from(&b"out"[..]), false);
|
||||
let u = s.usage();
|
||||
assert_eq!(u["projects"], serde_json::json!({"count": 1, "bytes": 4}));
|
||||
assert_eq!(u["media"], serde_json::json!({"count": 2, "bytes": 12}), "render outputs are not stored");
|
||||
assert_eq!(u["autoSaves"], serde_json::json!({"count": 1, "bytes": 4}));
|
||||
assert_eq!(FileKind::from_name("autoSaves"), Some(FileKind::AutoSave));
|
||||
assert_eq!(FileKind::of("/x/My auto-save 3.ecproj"), FileKind::AutoSave);
|
||||
s.lock().take_pending();
|
||||
assert_eq!(s.clear_kind(FileKind::Media), (2, 12));
|
||||
assert!(s.file("/clip.mp4").is_none() && s.file("/a.ecproj").is_some() && s.file("/render.gif").is_some());
|
||||
let deleted: Vec<String> = s.lock().take_pending().into_iter().filter(|p| p.data.is_none()).map(|p| p.key).collect();
|
||||
assert_eq!(deleted.len(), 2, "the deletions are flushed to browser storage");
|
||||
assert_eq!(s.usage()["media"]["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_coalesce_per_key_in_order() {
|
||||
let mut m = Mirror::default();
|
||||
|
|
|
|||
|
|
@ -192,6 +192,71 @@ try {
|
|||
check(fwk.after.syncs >= 2, `project synced as diffs: ${JSON.stringify(fwk.after)}`);
|
||||
check(fwk.maxGapMs < 400, `event loop blocked ${fwk.maxGapMs} ms while scrubbing`);
|
||||
|
||||
// GPU effects in the browser (M13.24): each frame worker opens its own WebGPU device; frames
|
||||
// with effects go to the workers, which render them on the GPU in passes (awaiting the
|
||||
// device's readbacks), while the page's event loop stays free.
|
||||
const gw = await js(`(async () => {
|
||||
const t0 = performance.now();
|
||||
while (effectcraft.info().frameWorkers.gpu.some((g) => g === null) && performance.now() - t0 < 30000) await new Promise((r) => setTimeout(r, 200));
|
||||
return {gpu: effectcraft.info().frameWorkers.gpu, workerWebGpu: effectcraft.info().webgpu};
|
||||
})()`);
|
||||
report.steps.gpuWorkers = gw;
|
||||
if (gw.gpu.some((g) => typeof g === "string")) {
|
||||
report.steps.gpuWorkers.effects = await js(`(async () => {
|
||||
const before = effectcraft.info().frameWorkers;
|
||||
let maxGapMs = 0, last = performance.now();
|
||||
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
|
||||
await effectcraft.execute("layer.newSolid", {name: "GPU Blur Solid", color: "#cc3366", width: 300, height: 200});
|
||||
await effectcraft.execute("effect.apply", {effect: "Gaussian Blur"});
|
||||
await effectcraft.execute("prop.set", {path: "effects/#1/blurriness", value: 25});
|
||||
await effectcraft.execute("effect.apply", {effect: "Glow"});
|
||||
for (let k = 0; k < 6; k++) {
|
||||
await effectcraft.execute("time.set", {time: 0.3 + k / 5});
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
const t0 = performance.now();
|
||||
while (effectcraft.info().frameWorkers.gpuFrames < before.gpuFrames + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100));
|
||||
clearInterval(timer);
|
||||
const after = effectcraft.info().frameWorkers;
|
||||
// The worker's GPU frame against the page's CPU render of the same frame.
|
||||
let parity = null;
|
||||
for (let i = 0; i < 100 && !parity; i++) {
|
||||
parity = await effectcraft.workerFrameCheck({scale: 0.5}).catch(() => null);
|
||||
if (!parity) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
after.parity = parity;
|
||||
await effectcraft.execute("edit.undo", {});
|
||||
await effectcraft.execute("edit.undo", {});
|
||||
await effectcraft.execute("edit.undo", {});
|
||||
await effectcraft.execute("edit.undo", {});
|
||||
return {before, after, maxGapMs};
|
||||
})()`);
|
||||
const ge = report.steps.gpuWorkers.effects;
|
||||
report.steps.gpuWorkers.screenshot = await shot("01b-gpu-effects");
|
||||
check(ge.after.gpuFrames >= ge.before.gpuFrames + 3, `GPU effect frames rendered in workers: ${JSON.stringify(ge)}`);
|
||||
check(ge.after.lastPasses >= 2, `frames rendered in passes (deferred readbacks): ${ge.after.lastPasses}`);
|
||||
const pa = ge.after.parity;
|
||||
check(pa && pa.meanDiff < 0.5 && pa.over4 < 0.005, `GPU worker frame vs the CPU: ${JSON.stringify(pa)}`);
|
||||
check(ge.maxGapMs < 400, `event loop blocked ${ge.maxGapMs} ms while GPU workers rendered`);
|
||||
} else {
|
||||
report.steps.gpuWorkers.note = "no WebGPU in workers here: frame workers render on their CPU";
|
||||
}
|
||||
|
||||
// The storage manager (Settings ▸ Disk ▸ Browser Storage) and its commands.
|
||||
await js(`effectcraft.execute("storage.persist", {})`);
|
||||
const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`);
|
||||
report.steps.storage = st;
|
||||
check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`);
|
||||
check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
|
||||
await js(`effectcraft.execute("prefs.open", {page: "disk"})`);
|
||||
await sleep(800);
|
||||
report.steps.storage.screenshot = await shot("01c-storage-manager");
|
||||
const ids = await js(`effectcraft.request("ui.elements", {prefix: "settings.storage."}).then((l) => l.map((e) => e.id))`);
|
||||
report.steps.storage.elements = ids;
|
||||
check(ids.includes("settings.storage.usage") && ids.includes("settings.storage.clear.diskCache"), `storage manager widgets: ${ids}`);
|
||||
await js(`effectcraft.request("ui.click", {id: "settings.cancel"})`);
|
||||
await sleep(300);
|
||||
|
||||
// Render Queue: a short, small GIF → download. `renderQueue.render` waits by default; the
|
||||
// render still runs in a worker and the reply comes when it ends (the page keeps running).
|
||||
const rqT = Date.now();
|
||||
|
|
@ -302,6 +367,25 @@ try {
|
|||
await js(`effectcraft.execute("prefs.set", {key: "general.recentItems", value: 7})`);
|
||||
const saved = await js(`effectcraft.saveToBrowser("/persist-test.ecproj")`);
|
||||
await js(`effectcraft.execute("layer.newSolid", {name: "Unsaved Solid"})`);
|
||||
// Disk cache: frames the workers render now are stored in the Origin Private File System
|
||||
// (the CPU renderer sends every frame to the workers).
|
||||
await js(`effectcraft.execute("render.backend", {backend: "cpu"})`);
|
||||
const diskTimes = [0.4, 0.8, 1.2];
|
||||
const stored = await js(`(async () => {
|
||||
const w0 = (await effectcraft.execute("cache.diskStats", {})).writes;
|
||||
for (const t of ${JSON.stringify(diskTimes)}) {
|
||||
await effectcraft.execute("time.set", {time: t});
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
const t0 = performance.now();
|
||||
let st;
|
||||
do {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
st = await effectcraft.execute("cache.diskStats", {});
|
||||
} while (st.writes < w0 + ${diskTimes.length} && performance.now() - t0 < 60000);
|
||||
return {writesBefore: w0, after: st};
|
||||
})()`);
|
||||
check(stored.after.writes >= stored.writesBefore + diskTimes.length, `frames written to the disk cache: ${JSON.stringify(stored)}`);
|
||||
await sleep(1500); // the session snapshot is taken at most every second
|
||||
await js("effectcraft.flush()");
|
||||
const before = await js("effectcraft.listStored()");
|
||||
|
|
@ -321,6 +405,31 @@ try {
|
|||
check(paths.includes("/tone.wav") && paths.includes("/persist-test.ecproj"), `stored files: ${paths}`);
|
||||
check(recentItems === 7, `setting after reload: ${recentItems}`);
|
||||
check(rec.recentProjects[0] === "/persist-test.ecproj", `recent projects: ${rec.recentProjects}`);
|
||||
// The disk cache survived the reload: the same frames come from it, not from a render.
|
||||
report.steps.diskCache = await js(`(async () => {
|
||||
const t0 = performance.now();
|
||||
let st;
|
||||
do {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
st = await effectcraft.execute("cache.diskStats", {});
|
||||
} while (!st.loaded && performance.now() - t0 < 20000);
|
||||
const listed = st.entries;
|
||||
for (const t of ${JSON.stringify(diskTimes)}) {
|
||||
await effectcraft.execute("time.set", {time: t});
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
const t1 = performance.now();
|
||||
while (effectcraft.info().frameWorkers.diskHits < ${diskTimes.length} && performance.now() - t1 < 30000) await new Promise((r) => setTimeout(r, 200));
|
||||
return {listed, stats: await effectcraft.execute("cache.diskStats", {}), frameWorkers: effectcraft.info().frameWorkers};
|
||||
})()`);
|
||||
const dcr = report.steps.diskCache;
|
||||
dcr.stored = stored;
|
||||
check(dcr.listed >= diskTimes.length, `disk cache entries after reload: ${JSON.stringify(dcr)}`);
|
||||
check(dcr.frameWorkers.diskHits >= diskTimes.length, `cache hits after reload: ${JSON.stringify(dcr.frameWorkers)}`);
|
||||
const cleared = await js(`effectcraft.execute("storage.clear", {what: "diskCache"})`);
|
||||
const afterClear = await js(`effectcraft.execute("cache.diskStats", {})`);
|
||||
dcr.cleared = { cleared, afterClear };
|
||||
check(cleared.entries >= diskTimes.length && afterClear.entries === 0, `storage.clear diskCache: ${JSON.stringify(dcr.cleared)}`);
|
||||
await js(`effectcraft.execute("file.openRecent", {index: 0})`);
|
||||
const reopened = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
||||
check(reopened.includes("Persisted Solid") && !reopened.includes("Unsaved Solid"), `Open Recent: ${reopened}`);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@
|
|||
listStored: () => ec.listStored(),
|
||||
removeStored: (path) => ec.removeStored(path),
|
||||
flush: () => ec.flush(),
|
||||
// A frame rendered in a frame worker (its GPU by default) vs the page's CPU: {maxDiff, meanDiff, over4, …}.
|
||||
workerFrameCheck: (params) => ec.workerFrameCheck(params ?? {}).then(JSON.parse),
|
||||
};
|
||||
// Offline start and install (PWA): the service worker caches the app's files.
|
||||
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
|
||||
|
|
|
|||
|
|
@ -2,21 +2,35 @@
|
|||
// crates/engine/src/offload.rs and remote.rs). The page sends the compiled module, then:
|
||||
// - job workers: footage files and jobs (Render Queue, analyses, Roto Brush); the worker replies
|
||||
// with JSON messages and rendered files;
|
||||
// - frame workers: footage files and frame messages (project syncs as diffs, render requests,
|
||||
// Roto Brush segmentations); the worker replies with frames (pixels transferred).
|
||||
// - frame workers (`frames: true`): footage files and frame messages (project syncs as diffs,
|
||||
// render requests, Roto Brush segmentations); the worker replies with frames (pixels
|
||||
// transferred). With `gpu: true` it opens its own WebGPU device and renders GPU effects on
|
||||
// it; a frame then takes several passes, awaiting the device's readbacks in between.
|
||||
// Messages are handled one at a time, in order: a frame awaiting its readbacks finishes before
|
||||
// the next message (a project sync, the next request) is looked at.
|
||||
import init, * as ec from "./effectcraft_web.js";
|
||||
|
||||
let ready = null;
|
||||
self.onmessage = async (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "init") {
|
||||
ready = init({ module_or_path: m.module ?? new URL("effectcraft_web_bg.wasm", m.base) }).then(() => ec.workerInit());
|
||||
await ready;
|
||||
self.postMessage({ type: "ready" });
|
||||
return;
|
||||
}
|
||||
await ready;
|
||||
let queue = Promise.resolve();
|
||||
|
||||
async function handle(m) {
|
||||
if (m.type === "file") ec.workerFile(m.path, m.bytes);
|
||||
else if (m.type === "job") ec.workerJob(m.json);
|
||||
else if (m.type === "frame") ec.workerFrame(m.json);
|
||||
else if (m.type === "frame") await ec.workerFrame(m.json);
|
||||
}
|
||||
|
||||
self.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "init") {
|
||||
ready = (async () => {
|
||||
await init({ module_or_path: m.module ?? new URL("effectcraft_web_bg.wasm", m.base) });
|
||||
ec.workerInit();
|
||||
if (m.frames) await ec.workerFrameInit(!!m.gpu);
|
||||
self.postMessage({ type: "ready" });
|
||||
})();
|
||||
return;
|
||||
}
|
||||
// A failure (a panic) is re-raised outside the queue so the page's `onerror` sees it, as
|
||||
// before the queue: the page then drops the worker.
|
||||
queue = queue.then(() => ready).then(() => handle(m)).catch((err) => setTimeout(() => { throw err; }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,12 +94,14 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
|
|||
];
|
||||
|
||||
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
|
||||
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
|
||||
/// pinning).
|
||||
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants, Turbulent Displace's locked
|
||||
/// pinning, Warp's strong Fisheye / Twist and Timewarp with a Matte Layer).
|
||||
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
|
||||
// Timewarp's Matte Layer (foreground and background on their own vectors).
|
||||
"ec.time.timewarp" if ctx.params.get("matteLayer").and_then(effectcraft_keyframe::Value::as_layer).is_some() => return false,
|
||||
// Fisheye / Twist (a crease at the unit circle) bent past 50 % or with Horizontal /
|
||||
// Vertical Distortion: Newton's inverse wanders chaotically before converging near the
|
||||
// fold, so f32 lands on other pixels than the CPU's f64.
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ pub use transition::place_layer;
|
|||
// effectcraft-gpu fx_noise.
|
||||
pub use noise::remove_grain_level;
|
||||
pub use noise3::{FractalGpu, fractal_gpu};
|
||||
pub use time_fx::{posterized_time, time_frames};
|
||||
pub use textfx::{TextLayer, text_layer};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
// effectcraft-gpu fx_tone.
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
|
||||
|
|
@ -931,6 +932,40 @@ pub const GPU_EFFECTS: &[&str] = &[
|
|||
"ec.perspective.bevelalpha",
|
||||
"ec.perspective.beveledges",
|
||||
"ec.obsolete.gaussianlegacy",
|
||||
// effectcraft-gpu fx_light (the CC light family)
|
||||
"ec.generate.cclightrays",
|
||||
"ec.generate.cclightburst",
|
||||
"ec.generate.cclightsweep",
|
||||
"ec.transition.cclightwipe",
|
||||
// effectcraft-gpu fx_transition (transitions, perspective)
|
||||
"ec.transition.blockdissolve",
|
||||
"ec.transition.ccglasswipe",
|
||||
"ec.transition.ccgridwipe",
|
||||
"ec.transition.ccimagewipe",
|
||||
"ec.transition.ccjaws",
|
||||
"ec.transition.cclinesweep",
|
||||
"ec.transition.ccradialscalewipe",
|
||||
"ec.transition.ccscalewipe",
|
||||
"ec.transition.cctwister",
|
||||
"ec.transition.ccwarpomatic",
|
||||
"ec.perspective.radialshadow",
|
||||
"ec.distort.ccbender",
|
||||
"ec.distort.ccblobbylize",
|
||||
"ec.perspective.cccylinder",
|
||||
"ec.perspective.ccsphere",
|
||||
"ec.perspective.ccspotlight",
|
||||
"ec.perspective.ccenvironment",
|
||||
"ec.perspective.3dglasses",
|
||||
// effectcraft-gpu fx_text (glyph coverage rasterised on the CPU)
|
||||
"ec.text.numbers",
|
||||
"ec.text.timecode",
|
||||
// effectcraft-gpu fx_time (frames fetched by the host, combined on the GPU)
|
||||
"ec.time.timedifference",
|
||||
"ec.time.timedisplacement",
|
||||
"ec.time.ccforcemotionblur",
|
||||
"ec.time.ccwidetime",
|
||||
"ec.time.pixelmotionblur",
|
||||
"ec.time.timewarp",
|
||||
];
|
||||
|
||||
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
|
||||
|
|
|
|||
|
|
@ -90,8 +90,10 @@ fn cc_environment(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let ex = u.rem_euclid(1.0) * esz[0] * es + eo[0];
|
||||
let ey = v.clamp(0.0, 1.0) * esz[1] * es + eo[1];
|
||||
let e = env_img.sample_bilinear_clamped(ex, ey);
|
||||
let (ec, _) = unpremul(e);
|
||||
*px = premul(ec, a);
|
||||
// Straight colour, with nearly transparent texels fading to black continuously
|
||||
// (a hard 1e-6 cut-off would turn the float noise of the filtered map into colour).
|
||||
let k = 1.0 / e[3].max(1e-3);
|
||||
*px = premul([e[0] * k, e[1] * k, e[2] * k], a);
|
||||
}
|
||||
});
|
||||
b
|
||||
|
|
|
|||
|
|
@ -192,9 +192,9 @@ fn tint(c: [f32; 4], a: f32) -> Px {
|
|||
[c[0] * a, c[1] * a, c[2] * a, a]
|
||||
}
|
||||
|
||||
/// Draw text polylines with the given look (`r` = glyph stroke radius in px).
|
||||
fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) {
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
/// Coverage of text polylines (`r` = glyph stroke radius in px): the fill and, when the look
|
||||
/// strokes, the stroke ring.
|
||||
fn text_coverage(w: usize, h: usize, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) -> (Plane, Option<Plane>) {
|
||||
let segs_at = |rad: f64| {
|
||||
let mut s = Vec::new();
|
||||
for pl in polys {
|
||||
|
|
@ -211,24 +211,35 @@ fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) {
|
|||
let inner = if r - sw * 0.5 > 0.05 { raster_segs(w, h, &segs_at(r - sw * 0.5), 1.0) } else { Plane::new(w, h) };
|
||||
Some(outer.zip_map(&inner, |a, b| (a - b).max(0.0)))
|
||||
};
|
||||
(fill, ring)
|
||||
}
|
||||
|
||||
/// One pixel of drawn text: `px` under the fill / stroke coverages `f`, `s` (opacity applied).
|
||||
fn text_px(px: Px, f: f32, s: f32, look: &TextLook) -> Px {
|
||||
let mut out = if look.on_original { px } else { [0.0; 4] };
|
||||
match look.display {
|
||||
1 => out = over(out, tint(look.stroke, s)),
|
||||
2 => {
|
||||
out = over(out, tint(look.stroke, s));
|
||||
out = over(out, tint(look.fill, f));
|
||||
}
|
||||
3 => {
|
||||
out = over(out, tint(look.fill, f));
|
||||
out = over(out, tint(look.stroke, s));
|
||||
}
|
||||
_ => out = over(out, tint(look.fill, f)),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw text polylines with the given look (`r` = glyph stroke radius in px).
|
||||
fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) {
|
||||
let (fill, ring) = text_coverage(b.img.width as usize, b.img.height as usize, polys, r, look);
|
||||
let op = look.opacity;
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let mut out = if look.on_original { *px } else { [0.0; 4] };
|
||||
let f = fill.data[i] * op;
|
||||
let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op;
|
||||
match look.display {
|
||||
1 => out = over(out, tint(look.stroke, s)),
|
||||
2 => {
|
||||
out = over(out, tint(look.stroke, s));
|
||||
out = over(out, tint(look.fill, f));
|
||||
}
|
||||
3 => {
|
||||
out = over(out, tint(look.fill, f));
|
||||
out = over(out, tint(look.stroke, s));
|
||||
}
|
||||
_ => out = over(out, tint(look.fill, f)),
|
||||
}
|
||||
*px = out;
|
||||
*px = text_px(*px, f, s, look);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -337,7 +348,18 @@ fn numbers_text(ctx: &EffectCtx) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
/// What Numbers or Timecode draws: glyph polylines, stroke radius and look, whether the layer
|
||||
/// stays under the text, and Timecode's box (`[x0, x1, y0, y1]` in buffer pixels, colour,
|
||||
/// opacity × colour alpha).
|
||||
struct TextPlan {
|
||||
polys: Vec<Vec<[f64; 2]>>,
|
||||
r: f64,
|
||||
look: TextLook,
|
||||
keep: bool,
|
||||
boxed: Option<([f64; 4], [f32; 4], f32)>,
|
||||
}
|
||||
|
||||
fn numbers_plan(ctx: &EffectCtx, b: &Buf) -> TextPlan {
|
||||
let flat = ungroup(ctx.params);
|
||||
let ctx = &EffectCtx { params: &flat, time: ctx.time, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
let pr = ctx.params;
|
||||
|
|
@ -349,11 +371,81 @@ fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let wdt = line_width(&text, size, tracking, prop);
|
||||
let mut polys = Vec::new();
|
||||
layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, tracking, prop, &mut polys);
|
||||
let look = look_from(ctx, &b, "compositeOnOriginal");
|
||||
draw_text(&mut b, &polys, weight(size), &look);
|
||||
let mut look = look_from(ctx, b, "compositeOnOriginal");
|
||||
let keep = look.on_original;
|
||||
look.on_original = true;
|
||||
TextPlan { polys, r: weight(size), look, keep, boxed: None }
|
||||
}
|
||||
|
||||
/// Timecode's box over pixel (`x`, `y`).
|
||||
fn box_px(px: Px, x: usize, y: usize, (r, bc, a): ([f64; 4], [f32; 4], f32)) -> Px {
|
||||
let [x0, x1, y0, y1] = r;
|
||||
let fy = y as f64 + 0.5;
|
||||
let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32;
|
||||
if cy <= 0.0 {
|
||||
return px;
|
||||
}
|
||||
let fx = x as f64 + 0.5;
|
||||
let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32;
|
||||
let k = cx * cy * a;
|
||||
if k > 0.0 { over(px, [bc[0] * k, bc[1] * k, bc[2] * k, k]) } else { px }
|
||||
}
|
||||
|
||||
fn draw_plan(mut b: Buf, plan: &TextPlan) -> Buf {
|
||||
let w = b.img.width as usize;
|
||||
let (fill, ring) = text_coverage(w, b.img.height as usize, &plan.polys, plan.r, &plan.look);
|
||||
let op = plan.look.opacity;
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let mut o = if plan.keep { *px } else { [0.0; 4] };
|
||||
if let Some(bx) = plan.boxed {
|
||||
o = box_px(o, i % w.max(1), i / w.max(1), bx);
|
||||
}
|
||||
let f = fill.data[i] * op;
|
||||
let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op;
|
||||
*px = text_px(o, f, s, &plan.look);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
fn numbers(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let plan = numbers_plan(ctx, &b);
|
||||
draw_plan(b, &plan)
|
||||
}
|
||||
|
||||
/// Numbers and Timecode for the GPU compositor (effectcraft-gpu `fx_text`): the glyph
|
||||
/// coverage is rasterised here (the fill in x, the stroke ring in y, opacity applied), the
|
||||
/// kernel composites it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextLayer {
|
||||
pub coverage: Image,
|
||||
/// Display Options (0 fill only, 1 stroke only, 2 fill over stroke, 3 stroke over fill).
|
||||
pub display: u32,
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
/// The layer stays under the text (else the text is drawn on transparency).
|
||||
pub keep: bool,
|
||||
/// Timecode's box: `[x0, x1, y0, y1]` (buffer pixels), colour, opacity × colour alpha.
|
||||
pub boxed: Option<([f64; 4], [f32; 4], f32)>,
|
||||
}
|
||||
|
||||
/// [`TextLayer`] of Numbers (`ec.text.numbers`) or Timecode (`ec.text.timecode`) on a buffer
|
||||
/// of `b`'s geometry.
|
||||
pub fn text_layer(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<TextLayer> {
|
||||
let plan = match id {
|
||||
"ec.text.numbers" => numbers_plan(ctx, b),
|
||||
"ec.text.timecode" => timecode_plan(ctx, b),
|
||||
_ => return None,
|
||||
};
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let (fill, ring) = text_coverage(w, h, &plan.polys, plan.r, &plan.look);
|
||||
let op = plan.look.opacity;
|
||||
let mut coverage = Image::new(b.img.width, b.img.height);
|
||||
coverage.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
*px = [fill.data[i] * op, ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op, 0.0, 0.0];
|
||||
});
|
||||
Some(TextLayer { coverage, display: plan.look.display, fill: plan.look.fill, stroke: plan.look.stroke, keep: plan.keep, boxed: plan.boxed })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Timecode
|
||||
|
||||
/// SMPTE drop-frame label for nominal 30/60 fps rates.
|
||||
|
|
@ -368,7 +460,7 @@ fn drop_frame(frames: i64, nominal: i64) -> String {
|
|||
format!("{:02};{:02};{:02};{:02}", adj / (nominal * 3600), (adj / (nominal * 60)) % 60, (adj / nominal) % 60, adj % nominal)
|
||||
}
|
||||
|
||||
fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
fn timecode_plan(ctx: &EffectCtx, b: &Buf) -> TextPlan {
|
||||
let pr = ctx.params;
|
||||
let src = pr.e("timeSource");
|
||||
let (t, fps) = match src {
|
||||
|
|
@ -397,36 +489,20 @@ fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let pos = b.to_px(pr.v2("textPosition"));
|
||||
let wdt = line_width(&text, size, 0.0, false);
|
||||
let op = (pr.f("opacity") / 100.0) as f32;
|
||||
if !pr.b("renderOnOriginal") {
|
||||
b.img = Image::new(b.img.width, b.img.height);
|
||||
}
|
||||
if pr.b("showBox") {
|
||||
let boxed = pr.b("showBox").then(|| {
|
||||
let bc = pr.color("boxColor");
|
||||
let pad = size * 0.25;
|
||||
let (x0, x1) = (pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad);
|
||||
let (y0, y1) = (pos.1 - size * 0.5, pos.1 + size * 0.5);
|
||||
let a = op * bc[3];
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
let fy = y as f64 + 0.5;
|
||||
let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32;
|
||||
if cy <= 0.0 {
|
||||
return;
|
||||
}
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let fx = x as f64 + 0.5;
|
||||
let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32;
|
||||
let k = cx * cy * a;
|
||||
if k > 0.0 {
|
||||
*px = over(*px, [bc[0] * k, bc[1] * k, bc[2] * k, k]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
([pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad, pos.1 - size * 0.5, pos.1 + size * 0.5], bc, op * bc[3])
|
||||
});
|
||||
let mut polys = Vec::new();
|
||||
layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, 0.0, false, &mut polys);
|
||||
let look = TextLook { display: 0, fill: pr.color("textColor"), stroke: [0.0; 4], stroke_w: 0.0, on_original: true, opacity: op };
|
||||
draw_text(&mut b, &polys, weight(size), &look);
|
||||
b
|
||||
TextPlan { polys, r: weight(size), look, keep: pr.b("renderOnOriginal"), boxed }
|
||||
}
|
||||
|
||||
fn timecode_fx(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let plan = timecode_plan(ctx, &b);
|
||||
draw_plan(b, &plan)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Basic Text
|
||||
|
|
|
|||
|
|
@ -680,23 +680,56 @@ fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option<V
|
|||
)
|
||||
}
|
||||
|
||||
/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two
|
||||
/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion
|
||||
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
|
||||
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
|
||||
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
|
||||
/// Samples instants across the shutter.
|
||||
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
if ctx.env.host.is_none() {
|
||||
return b;
|
||||
/// One output instant of a Timewarp without a Matte Layer: a fetched frame, a cross-fade of two,
|
||||
/// or the in-between frame built along a motion field ([`TimewarpPlan::flows`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum TwStep {
|
||||
Whole(usize),
|
||||
Mix(usize, usize, f32),
|
||||
Motion { a: usize, b: usize, w: f32, flow: usize },
|
||||
}
|
||||
|
||||
/// Timewarp without a Matte Layer, prepared for the GPU compositor (effectcraft-gpu `fx_time`):
|
||||
/// the fetched frames on one grid, the instants across the shutter (with their weights) and the
|
||||
/// motion fields (block matching on the CPU). [`TimewarpPlan::render_cpu`] is the CPU effect.
|
||||
pub struct TimewarpPlan {
|
||||
pub grid: Buf,
|
||||
pub frames: Vec<Image>,
|
||||
pub steps: Vec<(TwStep, f32)>,
|
||||
pub flows: Vec<Flow>,
|
||||
pub pm: PixelMotion,
|
||||
}
|
||||
|
||||
impl TimewarpPlan {
|
||||
pub fn render_cpu(&self) -> Buf {
|
||||
let f = &self.frames;
|
||||
let imgs: Vec<Image> = self
|
||||
.steps
|
||||
.iter()
|
||||
.map(|(s, _)| match *s {
|
||||
TwStep::Whole(i) => f[i].clone(),
|
||||
TwStep::Mix(i, j, w) => effectcraft_raster::flow::mix(&f[i], &f[j], w),
|
||||
TwStep::Motion { a, b, w, flow } => timewarp_interpolate(&f[a], &f[b], w, &self.flows[flow], &self.pm),
|
||||
})
|
||||
.collect();
|
||||
let weights: Vec<f32> = self.steps.iter().map(|(_, w)| *w).collect();
|
||||
let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) };
|
||||
with_img(self.grid.clone(), img)
|
||||
}
|
||||
}
|
||||
|
||||
/// Source-crop the edges of a frame (Pixel Motion works on cropped frames).
|
||||
pub fn timewarp_crop(img: &Image, pm: &PixelMotion) -> Image {
|
||||
crop_edges(img, pm.crops)
|
||||
}
|
||||
|
||||
/// The source instants Timewarp shows at the frame (with their weights) and the layer times
|
||||
/// they need: (instants, frame times, frames per second).
|
||||
fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec<f64>, f64) {
|
||||
let fps = ctx.fps();
|
||||
let by_frame = ctx.params.e("adjustTimeBy") == 1;
|
||||
let speed = ctx.params.f("speed");
|
||||
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
|
||||
let method = ctx.params.e("method");
|
||||
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
|
||||
// Instants of source time (with weights) across the shutter.
|
||||
let mut instants: Vec<(f64, f32)> = vec![];
|
||||
if ctx.params.b("motionBlur/enableMotionBlur") {
|
||||
let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 };
|
||||
|
|
@ -709,18 +742,88 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
|||
} else {
|
||||
instants.push((src, 1.0));
|
||||
}
|
||||
// Each instant needs the frame at or before it and the next one.
|
||||
let split = |s: f64| {
|
||||
let f = s * fps;
|
||||
let f0 = (f + 1e-6).floor();
|
||||
(f0, ((f - f0) as f32).max(0.0))
|
||||
};
|
||||
let mut times: Vec<f64> = vec![];
|
||||
for &(s, _) in &instants {
|
||||
let (f0, _) = split(s);
|
||||
let (f0, _) = tw_split(s, fps);
|
||||
times.push(f0 / fps);
|
||||
times.push((f0 + 1.0) / fps);
|
||||
}
|
||||
(instants, times, fps)
|
||||
}
|
||||
|
||||
/// The frame at or before source time `s` and the fraction to the next one.
|
||||
fn tw_split(s: f64, fps: f64) -> (f64, f32) {
|
||||
let f = s * fps;
|
||||
let f0 = (f + 1e-6).floor();
|
||||
(f0, ((f - f0) as f32).max(0.0))
|
||||
}
|
||||
|
||||
/// [`TimewarpPlan`] of a Timewarp instance without a Matte Layer. `None` without the effect host
|
||||
/// (the layer passes through) or with a Matte Layer.
|
||||
pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
|
||||
ctx.env.host?;
|
||||
if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() {
|
||||
return None;
|
||||
}
|
||||
let (instants, times, fps) = timewarp_instants(ctx);
|
||||
let method = ctx.params.e("method");
|
||||
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
|
||||
let fr = fetch(ctx, ×)?;
|
||||
let pm = PixelMotion::from(ctx, fr.grid.scale);
|
||||
let warps = fetch_layer(ctx, "warpLayer", ×, &fr.grid);
|
||||
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
|
||||
let mut flows: Vec<(i64, Flow)> = vec![];
|
||||
let mut steps = vec![];
|
||||
for &(s, wgt) in &instants {
|
||||
let (f0, frac) = tw_split(s, fps);
|
||||
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
|
||||
let step = if method == 0 || frac < 1e-4 || (one && method == 1) {
|
||||
let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() };
|
||||
TwStep::Whole(if pick < 0.5 { i0 } else { i1 })
|
||||
} else if method == 1 {
|
||||
TwStep::Mix(i0, i1, frac)
|
||||
} else {
|
||||
let key = f0 as i64;
|
||||
let flow = match flows.iter().position(|(k, _)| *k == key) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
let f = match &warps {
|
||||
Some(w) => timewarp_flow(&w[i0], &w[i1], &pm),
|
||||
None => timewarp_flow(&fr.imgs[i0], &fr.imgs[i1], &pm),
|
||||
};
|
||||
flows.push((key, f));
|
||||
flows.len() - 1
|
||||
}
|
||||
};
|
||||
TwStep::Motion { a: i0, b: i1, w: frac, flow }
|
||||
};
|
||||
steps.push((step, wgt));
|
||||
}
|
||||
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|(_, f)| f).collect(), pm })
|
||||
}
|
||||
|
||||
/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two
|
||||
/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion
|
||||
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
|
||||
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
|
||||
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
|
||||
/// Samples instants across the shutter.
|
||||
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
if ctx.env.host.is_none() {
|
||||
return b;
|
||||
}
|
||||
if ctx.params.get("matteLayer").and_then(Value::as_layer).is_none() {
|
||||
return match timewarp_plan(ctx) {
|
||||
Some(plan) => plan.render_cpu(),
|
||||
None => b,
|
||||
};
|
||||
}
|
||||
// Instants of source time (with weights) across the shutter; each needs the frame at or
|
||||
// before it and the next one.
|
||||
let (instants, times, fps) = timewarp_instants(ctx);
|
||||
let split = |s: f64| tw_split(s, fps);
|
||||
let method = ctx.params.e("method");
|
||||
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
|
||||
let Some(fr) = fetch(ctx, ×) else { return b };
|
||||
let grid = fr.grid.clone();
|
||||
let frame = |t: f64| -> &Image {
|
||||
|
|
|
|||
|
|
@ -549,6 +549,9 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if disk && let Some(dc) = &s.disk_cache {
|
||||
disk_entries = dc.stats().entries;
|
||||
dc.clear();
|
||||
} else if disk && let Some(h) = &s.storage {
|
||||
// The browser's disk cache (Origin Private File System).
|
||||
disk_entries = h.clear("diskCache").ok().and_then(|r| r["entries"].as_u64()).unwrap_or(0) as usize;
|
||||
}
|
||||
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
|
||||
effectcraft_effects::roto::purge();
|
||||
|
|
@ -560,7 +563,11 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// `cache.diskStats`: the disk cache's folder, limit and contents.
|
||||
pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(dc) = &s.disk_cache else { return Ok(json!({"enabled": false})) };
|
||||
let Some(dc) = &s.disk_cache else {
|
||||
// The browser's disk cache (Origin Private File System), when the web app has one.
|
||||
let web = s.storage.as_ref().map(|h| h.info()["diskCache"].clone()).filter(|d| d.is_object());
|
||||
return Ok(web.unwrap_or_else(|| json!({"enabled": false})));
|
||||
};
|
||||
let st = dc.stats();
|
||||
Ok(json!({
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(align::specs());
|
||||
v.extend(content_fill::specs());
|
||||
v.extend(stubs::specs());
|
||||
v.extend(crate::storage::specs());
|
||||
v.extend(crate::learn::specs());
|
||||
v.extend(crate::templates::specs());
|
||||
v.extend(crate::preview::specs());
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ pub mod roto;
|
|||
pub mod scriptui;
|
||||
mod session_settings;
|
||||
pub mod shortcuts;
|
||||
pub mod storage;
|
||||
pub mod sysinfo;
|
||||
pub mod templates;
|
||||
pub mod tracking;
|
||||
|
|
@ -363,6 +364,8 @@ pub struct Session {
|
|||
pub offloaded: Vec<offload::OffloadedJob>,
|
||||
/// What the Media Browser browses (`None`: the local file system where there is one).
|
||||
pub browser: Option<Arc<dyn media_browser::Browser>>,
|
||||
/// The browser's storage (the web app; Settings ▸ Disk ▸ Browser Storage, `storage.*`).
|
||||
pub storage: Option<Arc<dyn storage::StorageHost>>,
|
||||
/// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one
|
||||
/// (`wait: true` over the control channel in the browser) learns its outcome.
|
||||
pub offload_log: Vec<(offload::JobKind, Option<String>)>,
|
||||
|
|
@ -447,6 +450,7 @@ impl Default for Session {
|
|||
offloaded: vec![],
|
||||
offload_log: vec![],
|
||||
browser: None,
|
||||
storage: None,
|
||||
tasks: vec![],
|
||||
job_log: vec![],
|
||||
next_task_id: 1,
|
||||
|
|
|
|||
|
|
@ -819,6 +819,9 @@ pub enum Item {
|
|||
},
|
||||
/// Read-only information line.
|
||||
Note(&'static str),
|
||||
/// The browser's storage manager (web app only, `storage.*`): usage and quota, persistent
|
||||
/// storage, Clear buttons. Not shown where there is no browser storage.
|
||||
BrowserStorage,
|
||||
}
|
||||
|
||||
/// One page of the Settings dialog.
|
||||
|
|
@ -1035,6 +1038,7 @@ pub fn pages() -> Vec<Page> {
|
|||
s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), true),
|
||||
s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, true),
|
||||
Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# },
|
||||
BrowserStorage,
|
||||
Section("Media Cache"),
|
||||
s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, true),
|
||||
s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, true),
|
||||
|
|
@ -1143,6 +1147,7 @@ pub fn pages_json() -> Value {
|
|||
Item::Labels => json!({"labels": "labels.N.name / labels.N.color (N = 0..15)"}),
|
||||
Item::AudioDevices { key } => json!({"type": "device", "key": key, "label": "Default Output"}),
|
||||
Item::Note(t) => json!({"note": t}),
|
||||
Item::BrowserStorage => json!({"browserStorage": "storage.info / storage.persist / storage.clear (web app)"}),
|
||||
})
|
||||
.collect();
|
||||
json!({"id": p.id, "title": p.title, "items": items})
|
||||
|
|
|
|||
|
|
@ -13,13 +13,23 @@
|
|||
//! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render
|
||||
//! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]).
|
||||
//!
|
||||
//! - GPU frames: a worker with its own WebGPU device ([`FrameServer::accel`]) renders through
|
||||
//! the GPU compositor and GPU effects. Its readbacks can't be waited for, so a frame renders
|
||||
//! in passes ([`effectcraft_render::Accelerator::frame_begin`]): [`FrameServer::handle`]
|
||||
//! runs the first, and while [`FrameServer::waiting`] the transport awaits the device and
|
||||
//! calls [`FrameServer::resume`] for the next. Backend Auto picks CPU or GPU per comp from
|
||||
//! the frames' total times ([`effectcraft_render::AutoPick`]).
|
||||
//! - Disk cache: a render request may carry the frame's disk-cache key ([`FrameMsg::Render`]
|
||||
//! `disk`); the transport stores the finished frame under it (the browser: in the Origin
|
||||
//! Private File System) and reports [`FrameReply::Stored`].
|
||||
//!
|
||||
//! The transport (structured-clone messages, transferable pixel buffers) is the web crate's;
|
||||
//! everything here is portable and tested natively.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{ItemId, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource, Image, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_render::{Accelerator, AutoKey, Backend, ExprHost, FootageSource, Image, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
|
@ -120,8 +130,17 @@ pub enum FrameMsg {
|
|||
Project { revision: u64, project: String },
|
||||
/// Changes from the worker's revision `from` to `revision`.
|
||||
Patch { from: u64, revision: u64, ops: Vec<Op> },
|
||||
/// Render a frame of the current project.
|
||||
Render { id: u64, revision: u64, comp: ItemId, time: Tick, opts: Box<RenderOpts> },
|
||||
/// Render a frame of the current project. `disk`: the frame's disk-cache key (32 hex
|
||||
/// digits) when the transport should store it.
|
||||
Render {
|
||||
id: u64,
|
||||
revision: u64,
|
||||
comp: ItemId,
|
||||
time: Tick,
|
||||
opts: Box<RenderOpts>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
disk: Option<String>,
|
||||
},
|
||||
/// Roto Brush segmentations computed elsewhere (a propagation worker).
|
||||
Segs { segs: Vec<SegData> },
|
||||
/// Drop cached layer buffers (Edit ▸ Purge).
|
||||
|
|
@ -133,8 +152,27 @@ pub enum FrameMsg {
|
|||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum FrameReply {
|
||||
/// Premultiplied RGBA8 pixels of request `id`, rendered in `ms` milliseconds.
|
||||
Frame { id: u64, width: u32, height: u32, ms: f64 },
|
||||
/// Premultiplied RGBA8 pixels of request `id`, rendered in `ms` milliseconds (`passes`
|
||||
/// render passes, `gpu`: through the GPU accelerator).
|
||||
Frame {
|
||||
id: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
ms: f64,
|
||||
#[serde(default)]
|
||||
passes: u32,
|
||||
#[serde(default)]
|
||||
gpu: bool,
|
||||
},
|
||||
/// The frame was stored in the disk cache under `key` (`bytes` on disk).
|
||||
Stored { key: String, bytes: u64 },
|
||||
/// The worker's renderer: its GPU adapter, or why it has none.
|
||||
Info {
|
||||
#[serde(default)]
|
||||
gpu: Option<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Request `id` was not rendered (`stale`: the worker holds another revision).
|
||||
Failed { id: u64, error: String, stale: bool },
|
||||
/// A sync could not be applied: the page must send the whole project again.
|
||||
|
|
@ -189,14 +227,45 @@ pub struct FrameServer {
|
|||
pub footage: Arc<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub cache: Arc<LayerCache>,
|
||||
/// The worker's own accelerator (a GPU device; see [`FrameServer::set_accel`]).
|
||||
accel: Option<Arc<dyn Accelerator>>,
|
||||
doc: Value,
|
||||
project: Option<Arc<Project>>,
|
||||
revision: Option<u64>,
|
||||
/// The frame being rendered in passes.
|
||||
pending: Option<Pending>,
|
||||
}
|
||||
|
||||
/// A frame whose readbacks are in flight.
|
||||
struct Pending {
|
||||
id: u64,
|
||||
project: Arc<Project>,
|
||||
comp: ItemId,
|
||||
time: Tick,
|
||||
opts: RenderOpts,
|
||||
t0: web_time::Instant,
|
||||
passes: u32,
|
||||
/// Backend Auto's choice: (timing key, GPU).
|
||||
auto: Option<(AutoKey, bool)>,
|
||||
}
|
||||
|
||||
/// Passes before a frame gives up on deferred readbacks and renders on the CPU.
|
||||
pub const MAX_PASSES: u32 = 24;
|
||||
|
||||
impl FrameServer {
|
||||
pub fn new(footage: Arc<dyn FootageSource>, expr: Option<Arc<dyn ExprHost>>) -> FrameServer {
|
||||
FrameServer { footage, expr, cache: Arc::new(LayerCache::default()), doc: Value::Null, project: None, revision: None }
|
||||
FrameServer { footage, expr, cache: Arc::new(LayerCache::default()), accel: None, doc: Value::Null, project: None, revision: None, pending: None }
|
||||
}
|
||||
|
||||
/// Render with `accel` when the request's backend asks for it (the worker's GPU). Its miss
|
||||
/// gate keeps placeholder buffers of unfinished passes out of the layer cache.
|
||||
pub fn set_accel(&mut self, accel: Option<Arc<dyn Accelerator>>) {
|
||||
self.cache.set_gate(accel.as_ref().and_then(|a| a.miss_gate()));
|
||||
self.accel = accel;
|
||||
}
|
||||
|
||||
pub fn accel(&self) -> Option<&Arc<dyn Accelerator>> {
|
||||
self.accel.as_ref()
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> Option<u64> {
|
||||
|
|
@ -207,6 +276,11 @@ impl FrameServer {
|
|||
self.project.as_ref()
|
||||
}
|
||||
|
||||
/// A frame is waiting for readbacks: await the device, then [`FrameServer::resume`].
|
||||
pub fn waiting(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
fn load(&mut self, revision: u64) -> Result<(), String> {
|
||||
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
|
||||
self.project = Some(Arc::new(p));
|
||||
|
|
@ -214,7 +288,8 @@ impl FrameServer {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle one message; returns the replies with their pixels.
|
||||
/// Handle one message; returns the replies with their pixels. A render that needs more
|
||||
/// passes returns nothing yet ([`FrameServer::waiting`]).
|
||||
pub fn handle(&mut self, msg: FrameMsg) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
match msg {
|
||||
FrameMsg::Project { revision, project } => {
|
||||
|
|
@ -246,7 +321,7 @@ impl FrameServer {
|
|||
self.cache.clear();
|
||||
vec![]
|
||||
}
|
||||
FrameMsg::Render { id, revision, comp, time, opts } => {
|
||||
FrameMsg::Render { id, revision, comp, time, opts, .. } => {
|
||||
let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)];
|
||||
if self.revision != Some(revision) {
|
||||
return fail(format!("the replica is at {:?}, not {revision}", self.revision), true);
|
||||
|
|
@ -255,17 +330,60 @@ impl FrameServer {
|
|||
if p.comp(comp).is_none() {
|
||||
return fail(format!("no composition {}", comp.0), false);
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut r = Renderer::new(&p, self.footage.as_ref(), *opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.cache);
|
||||
let img = r.comp_frame(comp, time);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
vec![(FrameReply::Frame { id, width: img.width, height: img.height, ms }, Some(rgba8_premultiplied(&img)))]
|
||||
let mut opts = *opts;
|
||||
let mut auto = None;
|
||||
if let Some(a) = &self.accel {
|
||||
// Auto: CPU or GPU for the whole frame (all its passes), timed as a whole.
|
||||
if opts.backend == Backend::Auto {
|
||||
if !p.settings.gpu_acceleration {
|
||||
opts.backend = Backend::Cpu;
|
||||
} else if opts.roi.is_none()
|
||||
&& let Some(pick) = a.auto_pick()
|
||||
{
|
||||
let key = AutoKey::new(comp, opts.scale, false);
|
||||
let gpu = pick.choose(key);
|
||||
opts.backend = if gpu { Backend::Gpu } else { Backend::Cpu };
|
||||
auto = Some((key, gpu));
|
||||
}
|
||||
}
|
||||
a.frame_begin();
|
||||
}
|
||||
self.pending = Some(Pending { id, project: p, comp, time, opts, t0: web_time::Instant::now(), passes: 0, auto });
|
||||
self.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the next pass of the frame in progress; its reply once it is done.
|
||||
pub fn resume(&mut self) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
let Some(mut pr) = self.pending.take() else { return vec![] };
|
||||
pr.passes += 1;
|
||||
let accel = self.accel.clone().filter(|_| pr.opts.backend != Backend::Cpu);
|
||||
let give_up = pr.passes > MAX_PASSES;
|
||||
let mut r = Renderer::new(&pr.project, self.footage.as_ref(), pr.opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.cache);
|
||||
if !give_up {
|
||||
r.accel = accel.as_deref();
|
||||
}
|
||||
if let Some(a) = r.accel {
|
||||
a.pass_begin();
|
||||
}
|
||||
let img = r.comp_frame(pr.comp, pr.time);
|
||||
if r.accel.is_some_and(|a| a.pass_missed()) {
|
||||
self.pending = Some(pr);
|
||||
return vec![];
|
||||
}
|
||||
let ms = pr.t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let gpu = r.accel.is_some() && r.active_accel().is_some();
|
||||
if let (Some((key, chose)), Some(a)) = (pr.auto, &self.accel)
|
||||
&& let Some(pick) = a.auto_pick()
|
||||
{
|
||||
pick.record(key, chose, ms);
|
||||
}
|
||||
vec![(FrameReply::Frame { id: pr.id, width: img.width, height: img.height, ms, passes: pr.passes, gpu }, Some(rgba8_premultiplied(&img)))]
|
||||
}
|
||||
|
||||
fn lost(&mut self, error: String) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
self.doc = Value::Null;
|
||||
self.project = None;
|
||||
|
|
@ -332,7 +450,8 @@ mod tests {
|
|||
assert!(send(first, &mut w).is_empty());
|
||||
assert!(m.sync(s.revision, &s.project).is_none(), "nothing new");
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let render = |w: &mut FrameServer, rev: u64, id: u64| w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts) }));
|
||||
let render =
|
||||
|w: &mut FrameServer, rev: u64, id: u64| w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts), disk: None }));
|
||||
let out = render(&mut w, s.revision, 1);
|
||||
let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
|
||||
assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len()));
|
||||
|
|
@ -367,6 +486,7 @@ mod tests {
|
|||
comp: ItemId(2),
|
||||
time: Tick::from_seconds_f64(0.5),
|
||||
opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }),
|
||||
disk: Some(format!("{:032x}", 0xabcdu128)),
|
||||
},
|
||||
] {
|
||||
let a = serde_json::to_string(&m).unwrap();
|
||||
|
|
@ -374,11 +494,145 @@ mod tests {
|
|||
assert!(a.starts_with("{\"type\":"), "{a}");
|
||||
}
|
||||
for r in [
|
||||
FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5 },
|
||||
FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5, passes: 2, gpu: true },
|
||||
FrameReply::Stored { key: "00ff".into(), bytes: 9 },
|
||||
FrameReply::Info { gpu: Some("Adapter (BrowserWebGpu)".into()), error: None },
|
||||
FrameReply::Failed { id: 1, error: "x".into(), stale: true },
|
||||
FrameReply::Resync { error: "y".into() },
|
||||
] {
|
||||
assert_eq!(round(&r), r);
|
||||
}
|
||||
}
|
||||
|
||||
/// An accelerator with deferred readbacks: a GPU effect chain is "read back" one
|
||||
/// [`Deferred::settle`] after it was first asked for (computed with the CPU effects, so the
|
||||
/// finished frame equals the CPU render); until then it misses and returns its input.
|
||||
#[derive(Default)]
|
||||
struct Deferred {
|
||||
ready: std::sync::Mutex<std::collections::HashSet<u64>>,
|
||||
asked: std::sync::Mutex<std::collections::HashSet<u64>>,
|
||||
gate: Arc<std::sync::atomic::AtomicBool>,
|
||||
chains: std::sync::atomic::AtomicUsize,
|
||||
auto: effectcraft_render::AutoPick,
|
||||
}
|
||||
|
||||
impl Deferred {
|
||||
fn settle(&self) {
|
||||
let asked: Vec<u64> = self.asked.lock().unwrap().drain().collect();
|
||||
self.ready.lock().unwrap().extend(asked);
|
||||
}
|
||||
}
|
||||
|
||||
impl Accelerator for Deferred {
|
||||
fn name(&self) -> String {
|
||||
"deferred".into()
|
||||
}
|
||||
fn comp_frame(&self, _: &Renderer, _: ItemId, _: Tick) -> Option<Image> {
|
||||
None
|
||||
}
|
||||
fn supports_effect(&self, id: &str) -> bool {
|
||||
effectcraft_effects::GPU_EFFECTS.contains(&id)
|
||||
}
|
||||
fn effects(&self, chain: &[effectcraft_render::FxStep], buf: &effectcraft_effects::Buf, levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for s in chain {
|
||||
s.spec.id.hash(&mut h);
|
||||
s.ctx.time.to_bits().hash(&mut h);
|
||||
}
|
||||
(buf.img.width, buf.img.height).hash(&mut h);
|
||||
for p in &buf.img.data {
|
||||
for c in p {
|
||||
c.to_bits().hash(&mut h);
|
||||
}
|
||||
}
|
||||
let key = h.finish();
|
||||
if !self.ready.lock().unwrap().contains(&key) {
|
||||
self.asked.lock().unwrap().insert(key);
|
||||
self.gate.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
return Some(buf.clone());
|
||||
}
|
||||
self.chains.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut b = buf.clone();
|
||||
for s in chain {
|
||||
b = effectcraft_effects::apply(s.spec, &s.ctx, b);
|
||||
if let Some(l) = levels {
|
||||
effectcraft_render::color::quantize(&mut b.img, l);
|
||||
}
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
|
||||
Some(&self.auto)
|
||||
}
|
||||
fn frame_begin(&self) {
|
||||
self.ready.lock().unwrap().clear();
|
||||
}
|
||||
fn pass_begin(&self) {
|
||||
self.gate.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn pass_missed(&self) -> bool {
|
||||
self.gate.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
|
||||
Some(self.gate.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_readbacks_render_in_passes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap();
|
||||
let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() };
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let mut w = server(&s);
|
||||
let acc = Arc::new(Deferred::default());
|
||||
w.set_accel(Some(acc.clone()));
|
||||
let mut m = Mirror::default();
|
||||
w.handle(round(&m.sync(s.revision, &s.project).unwrap()));
|
||||
let req = |id| round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None });
|
||||
assert!(w.handle(req(1)).is_empty() && w.waiting(), "the first pass misses");
|
||||
let mut out = vec![];
|
||||
for _ in 0..10 {
|
||||
acc.settle();
|
||||
out = w.resume();
|
||||
if !w.waiting() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (FrameReply::Frame { id: 1, passes, gpu: true, .. }, Some(px)) = &out[0] else { panic!("{:?}", out.first().map(|o| &o.0)) };
|
||||
assert!(*passes >= 2, "{passes}");
|
||||
assert!(acc.chains.load(std::sync::atomic::Ordering::Relaxed) >= 2);
|
||||
// The same pixels as the CPU: no placeholder survived, in the frame or the layer cache.
|
||||
assert_eq!(*px, local(&s, comp, t, RenderOpts { backend: Backend::Cpu, ..opts }));
|
||||
let out = w.handle(req(2));
|
||||
let out = if out.is_empty() {
|
||||
acc.settle();
|
||||
w.resume()
|
||||
} else {
|
||||
out
|
||||
};
|
||||
assert_eq!(out[0].1.as_deref(), Some(&px[..]));
|
||||
// CPU requests never touch the accelerator; Auto times whole frames.
|
||||
let cpu = RenderOpts { backend: Backend::Cpu, ..opts };
|
||||
let out = w.handle(round(&FrameMsg::Render { id: 3, revision: s.revision, comp, time: t, opts: Box::new(cpu), disk: None }));
|
||||
assert!(matches!(out[0].0, FrameReply::Frame { id: 3, passes: 1, gpu: false, .. }));
|
||||
for id in 4..12 {
|
||||
let auto = RenderOpts { backend: Backend::Auto, ..opts };
|
||||
let mut out = w.handle(round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(auto), disk: None }));
|
||||
while w.waiting() {
|
||||
acc.settle();
|
||||
out = w.resume();
|
||||
}
|
||||
assert_eq!(out[0].1.as_deref(), Some(&px[..]), "frame {id}");
|
||||
}
|
||||
let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap();
|
||||
assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! Browser storage management: what the web app keeps in the browser (projects, imported media,
|
||||
//! auto-saves and settings in the Origin Private File System or IndexedDB; the disk cache's
|
||||
//! frames in the Origin Private File System), how much of the origin's quota that uses, whether
|
||||
//! the browser keeps it under storage pressure, and clearing it.
|
||||
//!
|
||||
//! The browser's storage APIs are asynchronous and the engine's commands are not, so a
|
||||
//! [`StorageHost`] answers from its latest snapshot (refreshing it in the background) and
|
||||
//! starts requests that finish later; the snapshot reports their outcome.
|
||||
//!
|
||||
//! Commands (agents, Settings ▸ Disk ▸ Browser Storage): `storage.info`, `storage.persist`,
|
||||
//! `storage.clear {what}`. Without a host (the desktop) `storage.info` reports
|
||||
//! `{available: false}` and the others fail.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, str_p};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
/// What can be cleared ([`StorageHost::clear`]).
|
||||
pub const CLEARABLE: &[&str] = &["diskCache", "media", "projects", "autoSaves", "all"];
|
||||
|
||||
/// The browser's storage, as the web app manages it.
|
||||
pub trait StorageHost: Send + Sync {
|
||||
/// The latest snapshot: `{backend, usage, quota, persisted, files: {count, bytes, media,
|
||||
/// projects, autoSaves}, diskCache: {enabled, entries, bytes, maxBytes, hits, misses, writes,
|
||||
/// evictions}}` (`usage` / `quota` from `navigator.storage.estimate()`, in bytes). Asks for
|
||||
/// a fresh one.
|
||||
fn info(&self) -> Value;
|
||||
/// Ask the browser to keep the storage under pressure (`navigator.storage.persist()`); the
|
||||
/// answer arrives in a later [`StorageHost::info`] (`persisted`).
|
||||
fn request_persist(&self) -> Value;
|
||||
/// Delete stored data: `diskCache` (cached frames), `media` (imported footage), `projects`
|
||||
/// (saved projects), `autoSaves`, or `all` of them (settings stay). Returns what was
|
||||
/// removed: `{cleared, entries, bytes}`.
|
||||
fn clear(&self, what: &str) -> std::result::Result<Value, String>;
|
||||
}
|
||||
|
||||
fn host(s: &Session, cmd: &str) -> Result<std::sync::Arc<dyn StorageHost>> {
|
||||
s.storage.clone().ok_or_else(|| crate::commands::bad(cmd, "browser storage is only managed in the web app"))
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(match &s.storage {
|
||||
Some(h) => {
|
||||
let mut v = h.info();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("available".into(), json!(true));
|
||||
}
|
||||
v
|
||||
}
|
||||
None => json!({"available": false}),
|
||||
})
|
||||
}
|
||||
|
||||
fn persist(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(host(s, "storage.persist")?.request_persist())
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("diskCache");
|
||||
if !CLEARABLE.contains(&what) {
|
||||
return Err(crate::commands::bad("storage.clear", format!("what: one of {}", CLEARABLE.join(", "))));
|
||||
}
|
||||
let h = host(s, "storage.clear")?;
|
||||
let r = h.clear(what).map_err(|e| crate::commands::bad("storage.clear", e))?;
|
||||
s.toast(format!("Cleared browser storage: {what}"));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn has_host(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.storage.is_some() { Ok(()) } else { Err("browser storage is only managed in the web app".into()) }
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
crate::query!(
|
||||
"storage.info",
|
||||
"Browser Storage",
|
||||
"{} → {available, backend, usage, quota, persisted, files: {count, bytes, media, projects, autoSaves}, diskCache: {enabled, entries, bytes, maxBytes, hits, misses, writes, evictions}}",
|
||||
info
|
||||
),
|
||||
cmd!("storage.persist", "Request Persistent Storage", [], None, "{} → {requested, persisted}", has_host, persist),
|
||||
cmd!("storage.clear", "Clear Browser Storage", [], None, "{what: diskCache|media|projects|autoSaves|all} → {cleared, entries, bytes}", has_host, clear),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mock {
|
||||
cleared: Mutex<Vec<String>>,
|
||||
persisted: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl StorageHost for Mock {
|
||||
fn info(&self) -> Value {
|
||||
json!({"backend": "opfs", "usage": 1000, "quota": 100000, "persisted": *self.persisted.lock().unwrap(),
|
||||
"diskCache": {"enabled": true, "entries": 3, "bytes": 300, "maxBytes": 1 << 30}})
|
||||
}
|
||||
fn request_persist(&self) -> Value {
|
||||
*self.persisted.lock().unwrap() = true;
|
||||
json!({"requested": true})
|
||||
}
|
||||
fn clear(&self, what: &str) -> std::result::Result<Value, String> {
|
||||
self.cleared.lock().unwrap().push(what.into());
|
||||
Ok(json!({"cleared": what, "entries": 3, "bytes": 300}))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_commands_go_to_the_host() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(s.execute_checked("storage.info", json!({})).unwrap(), json!({"available": false}));
|
||||
assert!(s.execute_checked("storage.clear", json!({"what": "diskCache"})).is_err());
|
||||
let m = Arc::new(Mock::default());
|
||||
s.storage = Some(m.clone());
|
||||
let i = s.execute_checked("storage.info", json!({})).unwrap();
|
||||
assert_eq!((i["available"].clone(), i["backend"].clone(), i["persisted"].clone()), (json!(true), json!("opfs"), json!(false)));
|
||||
assert_eq!(s.execute_checked("storage.persist", json!({})).unwrap()["requested"], json!(true));
|
||||
assert_eq!(s.execute_checked("storage.info", json!({})).unwrap()["persisted"], json!(true));
|
||||
assert!(s.execute_checked("storage.clear", json!({"what": "everything"})).is_err());
|
||||
assert_eq!(s.execute_checked("storage.clear", json!({"what": "media"})).unwrap()["cleared"], json!("media"));
|
||||
// The disk cache's own commands use the browser's disk cache when there is no folder.
|
||||
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
|
||||
assert_eq!((st["enabled"].clone(), st["entries"].clone()), (json!(true), json!(3)));
|
||||
assert_eq!(s.execute_checked("edit.purge", json!({"what": "disk"})).unwrap()["diskEntries"], json!(3));
|
||||
assert_eq!(*m.cleared.lock().unwrap(), vec!["media".to_string(), "diskCache".to_string()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -325,7 +325,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
|||
/// Rasterise a scene and read it back ([`effectcraft_render::Accelerator::raster_3d`]). `None`
|
||||
/// when the device can't (no readback, size or buffer limits).
|
||||
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if !g.can_readback() {
|
||||
if !g.can_wait() {
|
||||
return None;
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
|
|
@ -358,7 +358,7 @@ pub(crate) struct Post {
|
|||
dummy_out: wgpu::TextureView,
|
||||
}
|
||||
|
||||
const KERNELS: &[&str] = &["resolve", "radius_of", "boost", "prefix_rows", "gather", "finish", "wire"];
|
||||
const KERNELS: &[&str] = &["resolve", "radius_of", "boost", "prefix_rows", "gather", "finish", "wire", "occlude"];
|
||||
|
||||
impl Post {
|
||||
fn new(device: &wgpu::Device) -> Post {
|
||||
|
|
@ -520,7 +520,7 @@ fn storage_buf(g: &GpuContext, size: u64, label: &str) -> wgpu::Buffer {
|
|||
|
||||
/// Read a storage buffer back (submits and waits).
|
||||
fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
||||
if !e.g.can_readback() {
|
||||
if !e.g.can_wait() {
|
||||
return None;
|
||||
}
|
||||
let rb = e.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
|
|
@ -544,7 +544,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
|||
|
||||
/// A resolved run on the GPU: premultiplied linear (or working-space) colour and camera depth
|
||||
/// (`BIG` where nothing was drawn), one entry per output pixel.
|
||||
struct Resolved {
|
||||
pub(crate) struct Resolved {
|
||||
acc: wgpu::Buffer,
|
||||
depth: wgpu::Buffer,
|
||||
width: u32,
|
||||
|
|
@ -557,10 +557,10 @@ const BIG: f32 = 3.0e38;
|
|||
/// Rasterise every motion-blur sub-sample of a prepared run, resolve and average them, then
|
||||
/// apply the depth of field (`render_prepared`'s steps). `None` = can't run here (sizes,
|
||||
/// limits, or a depth of field without readback); `Some(None)` = nothing drawn.
|
||||
fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolved>> {
|
||||
pub(crate) fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolved>> {
|
||||
let (w, h) = prep.out;
|
||||
let g = e.g;
|
||||
if !g.fits(w, h) || (prep.dof.is_some() && !g.can_readback()) {
|
||||
if !g.fits(w, h) || (prep.dof.is_some() && !g.can_wait()) {
|
||||
return None;
|
||||
}
|
||||
let px = w as u64 * h as u64;
|
||||
|
|
@ -661,7 +661,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h):
|
|||
}
|
||||
|
||||
/// The resolved run as an image in the canvas's encoding, composited over `canvas` when given.
|
||||
fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage {
|
||||
pub(crate) fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage {
|
||||
let out = e.image(r.width, r.height);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let cv = canvas.map(|c| c.texture.create_view(&Default::default()));
|
||||
|
|
@ -673,7 +673,7 @@ fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) ->
|
|||
/// [`effectcraft_render::Accelerator::render_3d`]: the whole prepared run on the GPU, read
|
||||
/// back.
|
||||
pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendered> {
|
||||
if !g.can_readback() {
|
||||
if !g.can_wait() {
|
||||
return None;
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
|
|
@ -704,6 +704,38 @@ pub(crate) fn draw_run(e: &mut Enc, prep: &Prepared, canvas: &GpuImage) -> Optio
|
|||
}
|
||||
}
|
||||
|
||||
/// A layer rendered on its own (`iso`), finished, and hidden where the run's main scene is
|
||||
/// nearer (`adv::draw_run`'s 2D compositing path).
|
||||
pub(crate) fn occluded(e: &mut Enc, iso: &Resolved, main: &Resolved, encode: bool) -> GpuImage {
|
||||
let img = finish(e, iso, encode, None);
|
||||
let (w, h) = (iso.width, iso.height);
|
||||
let out = e.image(w, h);
|
||||
let (iv, ov) = (img.texture.create_view(&Default::default()), out.texture.create_view(&Default::default()));
|
||||
let b =
|
||||
Binds { color: Some(&iv), bufs: [None, Some(&iso.depth), Some(&main.acc), None, Some(&main.depth), None, None], out: Some(&ov), ..Default::default() };
|
||||
dispatch(e, "occlude", [w, h, 0, 0], [0.0; 4], b, pixel_groups(w, h));
|
||||
out
|
||||
}
|
||||
|
||||
/// Kernels of `sky.wgsl` (the shared compute module).
|
||||
pub(crate) const SKY_KERNELS: &[&str] = &["adv_sky"];
|
||||
|
||||
/// An Environment Light Background layer drawn over `canvas` (`three_d::compose::draw_sky`).
|
||||
pub(crate) fn sky(e: &mut Enc, sky: &effectcraft_render::three_d::SkyDraw, canvas: &GpuImage) -> Option<GpuImage> {
|
||||
let img = e.g.upload_buf(&sky.buf)?;
|
||||
let f3 = |v: [f64; 3], w: f64| [v[0] as f32, v[1] as f32, v[2] as f32, w as f32];
|
||||
let mut p = crate::context::Params::default();
|
||||
p.f[0] = f3(sky.fwd, sky.zoom);
|
||||
p.f[1] = f3(sky.right, sky.ortho as u32 as f64);
|
||||
p.f[2] = [sky.down[0] as f32, sky.down[1] as f32, sky.down[2] as f32, sky.rotation];
|
||||
let s = sky.scale;
|
||||
p.f[3] = [(sky.roi.0 / s - sky.view.0 / 2.0) as f32, (sky.roi.1 / s - sky.view.1 / 2.0) as f32, (1.0 / s) as f32, sky.opacity];
|
||||
p.f[4] = [img.width as f32, img.height as f32, 0.0, 0.0];
|
||||
let out = e.scratch(canvas.width, canvas.height);
|
||||
e.pixels("adv_sky", &p, canvas, Some(&img), &out, None);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Wireframe-quality layer outlines: `canvas` with `pixels` set to opaque white
|
||||
/// ([`effectcraft_render::Renderer::wireframe_pixels`]).
|
||||
pub(crate) fn wireframe(e: &mut Enc, canvas: &GpuImage, pixels: &[(u32, u32)]) -> GpuImage {
|
||||
|
|
|
|||
|
|
@ -262,6 +262,8 @@ pub struct GpuContext {
|
|||
/// GPU particle pipeline (built on first use) and simulation checkpoints.
|
||||
pub(crate) particles: crate::particles::PipesCell,
|
||||
pub(crate) particle_states: crate::particles::StatesCell,
|
||||
/// Deferred readbacks (a browser worker's WebGPU device), see [`crate::deferred`].
|
||||
pub(crate) deferred: Option<Arc<crate::deferred::Deferred>>,
|
||||
}
|
||||
|
||||
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
|
|
@ -314,6 +316,11 @@ impl GpuContext {
|
|||
include_str!("shaders/fx_tone.wgsl"),
|
||||
include_str!("shaders/fx_warp.wgsl"),
|
||||
include_str!("shaders/fx_extra.wgsl"),
|
||||
include_str!("shaders/fx_light.wgsl"),
|
||||
include_str!("shaders/fx_transition.wgsl"),
|
||||
include_str!("shaders/fx_text.wgsl"),
|
||||
include_str!("shaders/fx_time.wgsl"),
|
||||
include_str!("shaders/sky.wgsl"),
|
||||
]
|
||||
.concat();
|
||||
let module =
|
||||
|
|
@ -373,6 +380,11 @@ impl GpuContext {
|
|||
.chain(crate::fx_tone::KERNELS)
|
||||
.chain(crate::fx_warp::KERNELS)
|
||||
.chain(crate::fx_extra::KERNELS)
|
||||
.chain(crate::fx_light::KERNELS)
|
||||
.chain(crate::fx_transition::KERNELS)
|
||||
.chain(crate::fx_text::KERNELS)
|
||||
.chain(crate::fx_time::KERNELS)
|
||||
.chain(crate::adv3d::SKY_KERNELS)
|
||||
.map(|e| (e, &layout))
|
||||
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
|
||||
.map(|(e, layout)| {
|
||||
|
|
@ -442,27 +454,43 @@ impl GpuContext {
|
|||
adv3d: std::sync::OnceLock::new(),
|
||||
particles: Default::default(),
|
||||
particle_states: Default::default(),
|
||||
deferred: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A device of its own on the best adapter (CLI, tests, benchmarks). `None` when no
|
||||
/// adapter qualifies.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn headless() -> Option<GpuContext> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
)
|
||||
.ok()?;
|
||||
/// Switch to deferred readbacks (see [`crate::deferred`]): readbacks are never waited for.
|
||||
pub fn set_deferred(&mut self, on: bool) {
|
||||
self.deferred = on.then(Default::default);
|
||||
}
|
||||
|
||||
pub fn deferred(&self) -> Option<&Arc<crate::deferred::Deferred>> {
|
||||
self.deferred.as_ref()
|
||||
}
|
||||
|
||||
/// A device of its own on the best adapter, without blocking (a browser worker: WebGPU
|
||||
/// only; natively any backend). `Err` says why there is none.
|
||||
pub async fn request() -> Result<GpuContext, String> {
|
||||
#[allow(unused_mut)]
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
|
||||
}
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let limits = adapter.limits();
|
||||
let required_limits = wgpu::Limits {
|
||||
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
|
||||
max_buffer_size: limits.max_buffer_size,
|
||||
..wgpu::Limits::default()
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }))
|
||||
.ok()?;
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no device: {e}"))?;
|
||||
device.on_uncaptured_error(Arc::new(|e| {
|
||||
// Under test a validation error (a WGSL typo invalidates every kernel) fails loudly.
|
||||
if cfg!(test) {
|
||||
|
|
@ -470,12 +498,26 @@ impl GpuContext {
|
|||
}
|
||||
log::error!("wgpu: {e}")
|
||||
}));
|
||||
GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok()
|
||||
GpuContext::new(&adapter, device, queue)
|
||||
}
|
||||
|
||||
/// Readback (GPU → CPU) waits for the device; impossible on the browser's main thread.
|
||||
/// A device of its own on the best adapter (CLI, tests, benchmarks). `None` when no
|
||||
/// adapter qualifies.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn headless() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::request()).map_err(|e| log::info!("gpu: {e}")).ok()
|
||||
}
|
||||
|
||||
/// Readbacks (GPU → CPU) are possible: waited for natively, or deferred (a browser
|
||||
/// worker, [`crate::deferred`]). Impossible on the browser's main thread.
|
||||
pub fn can_readback(&self) -> bool {
|
||||
cfg!(not(target_arch = "wasm32"))
|
||||
self.can_wait() || self.deferred.is_some()
|
||||
}
|
||||
|
||||
/// Readbacks can be waited for (natively, unless deferred). Steps that read back without a
|
||||
/// key ([`crate::deferred`]) need this.
|
||||
pub fn can_wait(&self) -> bool {
|
||||
cfg!(not(target_arch = "wasm32")) && self.deferred.is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn fits(&self, w: u32, h: u32) -> bool {
|
||||
|
|
@ -792,7 +834,7 @@ impl<'g> Enc<'g> {
|
|||
|
||||
/// [`Enc::read_texture`] into `out` (`w × bpp × h` bytes), through a reused staging buffer.
|
||||
fn read_texture_into(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, out: &mut [u8]) -> Option<()> {
|
||||
if !self.g.can_readback() {
|
||||
if !self.g.can_wait() {
|
||||
return None;
|
||||
}
|
||||
self.g.transfers.count(false, out.len());
|
||||
|
|
@ -879,6 +921,25 @@ impl<'g> Enc<'g> {
|
|||
let _ = self.g.device.poll(wgpu::PollType::Poll);
|
||||
}
|
||||
|
||||
/// A deferred readback of `img` (RGBA f32) under `key` (see [`crate::deferred`]): the bytes
|
||||
/// once they arrived (`Some(None)`: the readback failed), else `None` after starting the
|
||||
/// copy if needed and marking the pass missed.
|
||||
pub(crate) fn read_keyed(&mut self, img: &GpuImage, key: u128, offset: [f64; 2], scale: f64) -> Option<Option<crate::deferred::Readback>> {
|
||||
use crate::deferred::Lookup;
|
||||
let d = self.g.deferred.clone()?;
|
||||
match d.lookup(key) {
|
||||
Lookup::Ready(r) => return Some(r),
|
||||
Lookup::InFlight => {}
|
||||
Lookup::Absent => {
|
||||
self.g.transfers.count(false, img.width as usize * img.height as usize * 16);
|
||||
let done = d.start(key, img.width, img.height, offset, scale);
|
||||
self.read_texture_async(&img.texture, img.width, img.height, 16, done);
|
||||
}
|
||||
}
|
||||
d.miss();
|
||||
None
|
||||
}
|
||||
|
||||
/// A data buffer for kernels that read tables (curves).
|
||||
pub fn data(&self, v: &[f32]) -> wgpu::Buffer {
|
||||
let mut bytes = Vec::with_capacity(v.len().max(4) * 4);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
//! Deferred readbacks: rendering on a device whose readbacks cannot be waited for (WebGPU in a
|
||||
//! browser worker: a buffer maps only once the worker returns to its event loop).
|
||||
//!
|
||||
//! A frame renders in passes (see [`effectcraft_render::Accelerator::frame_begin`]). Every
|
||||
//! readback the renderer needs is keyed by what determines its pixels: a GPU effect chain by
|
||||
//! its input buffer's pixels and the chain's parameters, the top-level frame by comp, time and
|
||||
//! render options. A key seen for the first time records the GPU work, starts an asynchronous
|
||||
//! copy (`map_async`) and *misses*: the step returns a placeholder and the pass is marked missed
|
||||
//! (raising the gate that keeps placeholder-derived buffers out of the layer cache). The caller
|
||||
//! awaits [`Deferred::settled`] and renders again; keys whose bytes arrived are served from
|
||||
//! here. A pass without a miss is the finished frame.
|
||||
//!
|
||||
//! Correctness: a result is only computed from genuine inputs. Pixels a chain reads are part
|
||||
//! of its key, and a chain whose kernels read other data through the effect host (other layers,
|
||||
//! other times) is not started when that data missed while it ran (the miss counter moved).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_render::FxStep;
|
||||
|
||||
/// Bytes read back for one key, with the geometry they describe.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Readback {
|
||||
pub bytes: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Layer buffer offset and scale (chains).
|
||||
pub offset: [f64; 2],
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// What [`Deferred::lookup`] knows about a key.
|
||||
pub(crate) enum Lookup {
|
||||
/// The bytes arrived (`None`: the readback failed; render this step another way).
|
||||
Ready(Option<Readback>),
|
||||
/// Started in this frame, not arrived yet.
|
||||
InFlight,
|
||||
/// Never asked for in this frame.
|
||||
Absent,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Frame generation: results of an older frame's readbacks are dropped.
|
||||
generation: u64,
|
||||
ready: HashMap<u128, Option<Readback>>,
|
||||
waiting: HashSet<u128>,
|
||||
in_flight: usize,
|
||||
/// Misses so far in this frame (a counter, so a chain sees misses during its own work).
|
||||
misses: u64,
|
||||
wakers: Vec<Waker>,
|
||||
}
|
||||
|
||||
/// Deferred readback state of a device (see the module docs).
|
||||
#[derive(Default)]
|
||||
pub struct Deferred {
|
||||
st: Mutex<State>,
|
||||
/// Raised from the pass's first miss to its end.
|
||||
gate: Arc<AtomicBool>,
|
||||
/// Passes and readbacks (diagnostics).
|
||||
pub(crate) passes: std::sync::atomic::AtomicU64,
|
||||
pub(crate) readbacks: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl Deferred {
|
||||
pub fn frame_begin(&self) {
|
||||
if let Ok(mut s) = self.st.lock() {
|
||||
s.generation += 1;
|
||||
s.ready.clear();
|
||||
s.waiting.clear();
|
||||
s.misses = 0;
|
||||
}
|
||||
self.gate.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn pass_begin(&self) {
|
||||
self.gate.store(false, Ordering::Relaxed);
|
||||
self.passes.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn missed(&self) -> bool {
|
||||
self.gate.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn gate(&self) -> Arc<AtomicBool> {
|
||||
self.gate.clone()
|
||||
}
|
||||
|
||||
/// Misses so far in this frame.
|
||||
pub(crate) fn misses(&self) -> u64 {
|
||||
self.st.lock().map(|s| s.misses).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mark the pass as missed.
|
||||
pub(crate) fn miss(&self) {
|
||||
if let Ok(mut s) = self.st.lock() {
|
||||
s.misses += 1;
|
||||
}
|
||||
self.gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn lookup(&self, key: u128) -> Lookup {
|
||||
let Ok(s) = self.st.lock() else { return Lookup::Ready(None) };
|
||||
if let Some(r) = s.ready.get(&key) {
|
||||
return Lookup::Ready(r.clone());
|
||||
}
|
||||
if s.waiting.contains(&key) { Lookup::InFlight } else { Lookup::Absent }
|
||||
}
|
||||
|
||||
/// A readback for `key` is being started; returns the callback that stores its bytes.
|
||||
pub(crate) fn start(self: &Arc<Self>, key: u128, width: u32, height: u32, offset: [f64; 2], scale: f64) -> impl FnOnce(Option<Vec<u8>>) + 'static {
|
||||
let generation = match self.st.lock() {
|
||||
Ok(mut s) => {
|
||||
s.waiting.insert(key);
|
||||
s.in_flight += 1;
|
||||
s.generation
|
||||
}
|
||||
Err(_) => 0,
|
||||
};
|
||||
self.readbacks.fetch_add(1, Ordering::Relaxed);
|
||||
let me = self.clone();
|
||||
move |bytes: Option<Vec<u8>>| {
|
||||
let wakers = match me.st.lock() {
|
||||
Ok(mut s) => {
|
||||
s.in_flight = s.in_flight.saturating_sub(1);
|
||||
if s.generation == generation {
|
||||
s.waiting.remove(&key);
|
||||
s.ready.insert(key, bytes.map(|bytes| Readback { bytes, width, height, offset, scale }));
|
||||
}
|
||||
if s.in_flight == 0 { std::mem::take(&mut s.wakers) } else { vec![] }
|
||||
}
|
||||
Err(_) => vec![],
|
||||
};
|
||||
for w in wakers {
|
||||
w.wake();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Readbacks still in flight.
|
||||
pub fn in_flight(&self) -> usize {
|
||||
self.st.lock().map(|s| s.in_flight).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Resolves once no readback is in flight (on the web the browser's event loop delivers
|
||||
/// them; natively a device poll does).
|
||||
pub fn settled(self: &Arc<Self>) -> Settled {
|
||||
Settled(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`Deferred::settled`].
|
||||
pub struct Settled(Arc<Deferred>);
|
||||
|
||||
impl Future for Settled {
|
||||
type Output = ();
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let Ok(mut s) = self.0.st.lock() else { return Poll::Ready(()) };
|
||||
if s.in_flight == 0 {
|
||||
return Poll::Ready(());
|
||||
}
|
||||
s.wakers.push(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// 128-bit key from two differently seeded std hashers (deterministic within a process).
|
||||
pub(crate) struct Key(std::collections::hash_map::DefaultHasher, std::collections::hash_map::DefaultHasher);
|
||||
|
||||
impl Key {
|
||||
pub fn new(tag: u64) -> Key {
|
||||
let mut a = std::collections::hash_map::DefaultHasher::new();
|
||||
let mut b = std::collections::hash_map::DefaultHasher::new();
|
||||
tag.hash(&mut a);
|
||||
(tag ^ 0x9e37_79b9_7f4a_7c15).hash(&mut b);
|
||||
0xa5u8.hash(&mut b);
|
||||
Key(a, b)
|
||||
}
|
||||
|
||||
pub fn bytes(&mut self, v: &[u8]) {
|
||||
self.0.write(v);
|
||||
self.1.write(v);
|
||||
}
|
||||
|
||||
pub fn u64(&mut self, v: u64) {
|
||||
self.bytes(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn f64(&mut self, v: f64) {
|
||||
self.u64(v.to_bits());
|
||||
}
|
||||
|
||||
pub fn debug(&mut self, v: &impl std::fmt::Debug) {
|
||||
self.bytes(format!("{v:?}").as_bytes());
|
||||
self.bytes(&[0xff]);
|
||||
}
|
||||
|
||||
pub fn finish(&self) -> u128 {
|
||||
((self.0.finish() as u128) << 64) | self.1.finish() as u128
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of a GPU effect chain on `buf`: the input pixels and geometry, the bit depth, and every
|
||||
/// step's effect, parameters (sorted) and context.
|
||||
pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> u128 {
|
||||
let mut k = Key::new(1);
|
||||
k.u64(buf.img.width as u64);
|
||||
k.u64(buf.img.height as u64);
|
||||
k.bytes(bytemuck::cast_slice(&buf.img.data));
|
||||
k.f64(buf.offset[0]);
|
||||
k.f64(buf.offset[1]);
|
||||
k.f64(buf.scale);
|
||||
k.debug(&levels);
|
||||
for s in chain {
|
||||
let c = &s.ctx;
|
||||
k.bytes(s.spec.id.as_bytes());
|
||||
let mut names: Vec<&String> = c.params.values.keys().collect();
|
||||
names.sort();
|
||||
for n in names {
|
||||
k.bytes(n.as_bytes());
|
||||
k.debug(&c.params.values[n]);
|
||||
}
|
||||
k.f64(c.time);
|
||||
k.f64(c.layer_size[0]);
|
||||
k.f64(c.layer_size[1]);
|
||||
k.u64(c.seed as u64);
|
||||
k.u64(c.adjustment as u64);
|
||||
let e = &c.env;
|
||||
k.debug(&e.masks);
|
||||
k.u64(e.host.is_some() as u64);
|
||||
k.f64(e.comp_time);
|
||||
k.f64(e.frame_rate);
|
||||
k.u64(e.effect_index as u64);
|
||||
k.debug(&e.working_space);
|
||||
k.u64(e.working_linear as u64);
|
||||
k.debug(&e.shutter);
|
||||
}
|
||||
k.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_stable_and_distinct() {
|
||||
let mut a = Key::new(1);
|
||||
a.bytes(b"abc");
|
||||
let mut b = Key::new(1);
|
||||
b.bytes(b"abc");
|
||||
let mut c = Key::new(2);
|
||||
c.bytes(b"abc");
|
||||
assert_eq!(a.finish(), b.finish());
|
||||
assert_ne!(a.finish(), c.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readbacks_resolve_and_old_frames_are_dropped() {
|
||||
let d = Arc::new(Deferred::default());
|
||||
d.frame_begin();
|
||||
d.pass_begin();
|
||||
assert!(matches!(d.lookup(7), Lookup::Absent));
|
||||
let done = d.start(7, 2, 1, [1.0, 2.0], 0.5);
|
||||
d.miss();
|
||||
assert!(d.missed() && d.gate().load(Ordering::Relaxed));
|
||||
assert!(matches!(d.lookup(7), Lookup::InFlight));
|
||||
assert_eq!(d.in_flight(), 1);
|
||||
// The settle future wakes when the last readback lands.
|
||||
let mut fut = std::pin::pin!(d.settled());
|
||||
let waker = Waker::noop();
|
||||
assert!(fut.as_mut().poll(&mut Context::from_waker(waker)).is_pending());
|
||||
done(Some(vec![1, 2, 3, 4, 5, 6, 7, 8]));
|
||||
assert!(fut.as_mut().poll(&mut Context::from_waker(waker)).is_ready());
|
||||
let Lookup::Ready(Some(r)) = d.lookup(7) else { panic!("not ready") };
|
||||
assert_eq!((r.width, r.height, r.offset, r.scale, r.bytes.len()), (2, 1, [1.0, 2.0], 0.5, 8));
|
||||
d.pass_begin();
|
||||
assert!(!d.missed());
|
||||
// A readback of the previous frame arriving late is dropped.
|
||||
let late = d.start(9, 1, 1, [0.0; 2], 1.0);
|
||||
d.frame_begin();
|
||||
late(Some(vec![0; 4]));
|
||||
assert!(matches!(d.lookup(9), Lookup::Absent));
|
||||
assert!(matches!(d.lookup(7), Lookup::Absent));
|
||||
assert_eq!(d.in_flight(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,20 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option
|
|||
if !e.g.can_readback() || buf.img.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Deferred readbacks (a browser worker): served by key once the bytes arrived.
|
||||
let deferred = e.g.deferred.clone();
|
||||
let key = deferred.as_ref().map(|_| crate::deferred::chain_key(chain, buf, levels));
|
||||
if let (Some(d), Some(k)) = (&deferred, key) {
|
||||
match d.lookup(k) {
|
||||
crate::deferred::Lookup::Ready(r) => return r.and_then(readback_buf),
|
||||
crate::deferred::Lookup::InFlight => {
|
||||
d.miss();
|
||||
return Some(buf.clone());
|
||||
}
|
||||
crate::deferred::Lookup::Absent => {}
|
||||
}
|
||||
}
|
||||
let misses = deferred.as_ref().map(|d| d.misses());
|
||||
let mut b = GBuf { img: e.g.upload_image(&buf.img)?, offset: buf.offset, scale: buf.scale };
|
||||
for step in chain {
|
||||
b = apply(e, step.spec.id, &step.ctx, b)?;
|
||||
|
|
@ -58,10 +72,30 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option
|
|||
b.img = ops::quantize(e, &b.img, l);
|
||||
}
|
||||
}
|
||||
if let (Some(d), Some(k)) = (&deferred, key) {
|
||||
// Data the kernels fetched through the effect host missed meanwhile: not genuine.
|
||||
if Some(d.misses()) != misses {
|
||||
d.miss();
|
||||
return Some(buf.clone());
|
||||
}
|
||||
// The placeholder (the input) keeps this pass cheap; the next pass gets the result.
|
||||
return match e.read_keyed(&b.img, k, b.offset, b.scale) {
|
||||
Some(r) => r.and_then(readback_buf),
|
||||
None => Some(buf.clone()),
|
||||
};
|
||||
}
|
||||
let img = e.download(&b.img)?;
|
||||
Some(Buf { img, offset: b.offset, scale: b.scale })
|
||||
}
|
||||
|
||||
/// A layer buffer from deferred readback bytes (RGBA f32).
|
||||
fn readback_buf(r: crate::deferred::Readback) -> Option<Buf> {
|
||||
let mut img = effectcraft_raster::Image::new(r.width, r.height);
|
||||
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut img.data);
|
||||
(dst.len() == r.bytes.len()).then(|| dst.copy_from_slice(&r.bytes))?;
|
||||
Some(Buf { img, offset: r.offset, scale: r.scale })
|
||||
}
|
||||
|
||||
/// An effect stack running on a GPU-resident image (adjustment layers): GPU chains stay on
|
||||
/// the device; CPU effects read the image back, run, and upload the result.
|
||||
pub(crate) struct GpuFx<'e, 'g> {
|
||||
|
|
@ -133,6 +167,10 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
|||
_ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b),
|
||||
_ if crate::fx_warp::IDS.contains(&id) => crate::fx_warp::apply(e, id, ctx, b),
|
||||
_ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b),
|
||||
_ if crate::fx_light::IDS.contains(&id) => crate::fx_light::apply(e, id, ctx, b),
|
||||
_ if crate::fx_transition::IDS.contains(&id) => crate::fx_transition::apply(e, id, ctx, b),
|
||||
_ if crate::fx_text::IDS.contains(&id) => crate::fx_text::apply(e, id, ctx, b),
|
||||
_ if crate::fx_time::IDS.contains(&id) => crate::fx_time::apply(e, id, ctx, b),
|
||||
_ => pointwise(e, id, ctx, b),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
//! GPU effects, the CC light family (kernels in `shaders/fx_light.wgsl`, every entry point
|
||||
//! prefixed `flt_`): CC Light Rays, CC Light Burst 2.5, CC Light Sweep and CC Light Wipe, with
|
||||
//! the CPU effects' exact steps. Centres, radii, the wipe's corner distance and ramp are
|
||||
//! computed on the CPU in f64 as the CPU effect does; the kernels work relative to the centre.
|
||||
//! Light Sweep's softened alpha plane is the CPU's `gauss_plane` (box passes, edges repeated).
|
||||
|
||||
use effectcraft_effects::EffectCtx;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::{GBuf, gaussian_blur};
|
||||
|
||||
/// Compute entry points in `fx_light.wgsl`.
|
||||
pub(crate) const KERNELS: &[&str] = &["flt_rays", "flt_burst", "flt_sweep", "flt_wipe"];
|
||||
|
||||
/// Effect ids implemented here.
|
||||
pub(crate) const IDS: &[&str] = &["ec.generate.cclightrays", "ec.generate.cclightburst", "ec.generate.cclightsweep", "ec.transition.cclightwipe"];
|
||||
|
||||
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
|
||||
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
match id {
|
||||
"ec.generate.cclightrays" => light_rays(e, ctx, b),
|
||||
"ec.generate.cclightburst" => light_burst(e, ctx, b),
|
||||
"ec.generate.cclightsweep" => light_sweep(e, ctx, b),
|
||||
"ec.transition.cclightwipe" => light_wipe(e, ctx, b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A same-size per-pixel kernel over the buffer.
|
||||
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, aux: Option<&GpuImage>) -> Option<GBuf> {
|
||||
let out = e.scratch(b.img.width, b.img.height);
|
||||
e.pixels(entry, p, &b.img, aux, &out, None);
|
||||
b.img = out;
|
||||
Some(b)
|
||||
}
|
||||
|
||||
fn rgb(c: [f32; 4]) -> [f32; 4] {
|
||||
[c[0], c[1], c[2], 0.0]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Rays / Burst (generate2.rs)
|
||||
|
||||
fn light_rays(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let k = (pr.f("intensity") / 100.0 * 0.5).clamp(0.0, 0.95);
|
||||
let gain = (pr.f("intensity") / 100.0 * 2.0) as f32;
|
||||
let rad = (pr.f("radius") * b.scale).max(0.5);
|
||||
let ws = (pr.f("warpSoftness") / 50.0).max(0.0);
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [(pr.e("shape") == 1) as u32, pr.b("colorFromSource") as u32, pr.e("transferMode"), 0];
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, k as f32, gain];
|
||||
p.f[1] = [rad as f32, (rad * (1.0 + ws)) as f32 + 1e-3, 0.0, 0.0];
|
||||
p.f[2] = rgb(pr.color("color"));
|
||||
run(e, "flt_rays", &p, b, None)
|
||||
}
|
||||
|
||||
fn light_burst(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let inten = (pr.f("intensity") / 100.0) as f32;
|
||||
let k = (pr.f("rayLength") / 100.0).clamp(0.0, 1.0);
|
||||
let set_color = pr.b("setColor");
|
||||
if k <= 0.0 && (inten - 1.0).abs() < 1e-6 && !set_color {
|
||||
return Some(b);
|
||||
}
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [pr.e("burst"), set_color as u32, 0, 0];
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, k as f32, inten];
|
||||
p.f[1] = rgb(pr.color("color"));
|
||||
run(e, "flt_burst", &p, b, None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Sweep (generate2.rs)
|
||||
|
||||
fn light_sweep(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let dir = pr.f("direction").to_radians();
|
||||
let half = (pr.f("width") * b.scale * 0.5).max(0.5);
|
||||
let edge_t = pr.f("edgeThickness") * b.scale;
|
||||
// The alpha plane (x), softened by util::gauss_plane (edges repeated) when thick enough.
|
||||
let alpha = e.scratch(b.img.width, b.img.height);
|
||||
e.pixels("fxe_alpha", &Params::default(), &b.img, None, &alpha, None);
|
||||
let soft = if edge_t > 0.05 { gaussian_blur(e, &alpha, edge_t * 0.5, edge_t * 0.5, true) } else { alpha };
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [pr.e("shape"), pr.e("lightReceptionMode"), 0, 0];
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, dir.cos() as f32, dir.sin() as f32];
|
||||
p.f[1] = [half as f32, (pr.f("sweepIntensity") / 100.0) as f32, (pr.f("edgeIntensity") / 100.0) as f32, 0.0];
|
||||
p.f[2] = rgb(pr.color("lightColor"));
|
||||
run(e, "flt_sweep", &p, b, Some(&soft))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Wipe (transition.rs)
|
||||
|
||||
fn light_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let done = (pr.f("completion") / 100.0).clamp(0.0, 1.0);
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let shape = pr.e("shape");
|
||||
let (s, co) = pr.f("direction").to_radians().sin_cos();
|
||||
let reverse = pr.b("reverse");
|
||||
let metric = |dx: f64, dy: f64| {
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
match shape {
|
||||
0 => qx.abs(),
|
||||
2 => qx.abs().max(qy.abs()),
|
||||
_ => qx.hypot(qy),
|
||||
}
|
||||
};
|
||||
// transition::max_corner_dist.
|
||||
let (w, h) = (b.img.width as f64, b.img.height as f64);
|
||||
let maxd = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| metric(x - c.0, y - c.1)).fold(1e-6, f64::max);
|
||||
let edge = if reverse { (1.0 - done) * maxd } else { done * maxd };
|
||||
let band = (maxd * 0.06).max(2.0);
|
||||
let ramp = (done * 20.0).min(1.0) as f32 * ((1.0 - done) * 20.0).min(1.0) as f32;
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [shape, pr.b("colorFromSource") as u32, reverse as u32, 0];
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, s as f32, co as f32];
|
||||
p.f[1] = [edge as f32, band as f32, pr.f("intensity") as f32 / 100.0 * ramp, 0.0];
|
||||
p.f[2] = rgb(pr.color("color"));
|
||||
run(e, "flt_wipe", &p, b, None)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
//! GPU effects, Numbers and Timecode (kernel `ftx_text` in `shaders/fx_text.wgsl`). The text
|
||||
//! is laid out and its glyph strokes rasterised on the CPU (`effectcraft_effects::text_layer`,
|
||||
//! the CPU effect's own coverage: fill and stroke ring), uploaded as one image and composited
|
||||
//! on the GPU with the layer, Timecode's box and the fill / stroke colours.
|
||||
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
use crate::context::{Enc, Params};
|
||||
use crate::effects::GBuf;
|
||||
|
||||
/// Compute entry points in `fx_text.wgsl`.
|
||||
pub(crate) const KERNELS: &[&str] = &["ftx_text"];
|
||||
|
||||
/// Effect ids implemented here.
|
||||
pub(crate) const IDS: &[&str] = &["ec.text.numbers", "ec.text.timecode"];
|
||||
|
||||
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
|
||||
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let shape = Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale };
|
||||
let t = effectcraft_effects::text_layer(id, ctx, &shape)?;
|
||||
let cov = e.g.upload_image(&t.coverage)?;
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [t.display, t.keep as u32, t.boxed.is_some() as u32, 0];
|
||||
p.f[0] = t.fill;
|
||||
p.f[1] = t.stroke;
|
||||
if let Some((r, c, a)) = t.boxed {
|
||||
p.f[2] = r.map(|v| v as f32);
|
||||
p.f[3] = [c[0], c[1], c[2], a];
|
||||
}
|
||||
let out = e.scratch(b.img.width, b.img.height);
|
||||
e.pixels("ftx_text", &p, &b.img, Some(&cov), &out, None);
|
||||
Some(GBuf { img: out, ..b })
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
//! GPU effects, time (kernels in `shaders/fx_time.wgsl`, every entry point prefixed `ftm_`):
|
||||
//! Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time, Pixel Motion Blur and
|
||||
//! Timewarp. Like Echo (`fx_noise`), the other frames come from the effect host on the CPU
|
||||
//! (`effectcraft_effects::time_frames`: fetched at the CPU effect's layer times and placed on
|
||||
//! one grid) and are uploaded; the kernels combine them with the CPU effect's arithmetic in its
|
||||
//! order (weighted sums one frame per pass, so the float rounding matches).
|
||||
//!
|
||||
//! Time Displacement's per-pixel time (the map's luminance, quantised to the time resolution)
|
||||
//! is computed on the CPU, since it decides which frames to fetch; the GPU gathers each pixel
|
||||
//! from its frame. Timewarp's motion vectors (block matching and smoothing,
|
||||
//! `effectcraft_raster::flow`) are estimated on the CPU (`effectcraft_effects::timewarp_plan`);
|
||||
//! the frame building (Whole Frames, Frame Mix, Pixel Motion's warp along the vectors with its
|
||||
//! error fallback, the shutter samples' average) runs on the GPU. Timewarp with a Matte Layer
|
||||
//! renders on the CPU (`catalog::gpu_supported`).
|
||||
|
||||
use effectcraft_effects::util::{fit_layer, unpremul};
|
||||
use effectcraft_effects::{Buf, EffectCtx, TwStep};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::GBuf;
|
||||
|
||||
/// Compute entry points in `fx_time.wgsl`.
|
||||
pub(crate) const KERNELS: &[&str] = &["ftm_acc", "ftm_mix", "ftm_difference", "ftm_displace", "ftm_motion"];
|
||||
|
||||
/// Effect ids implemented here.
|
||||
pub(crate) const IDS: &[&str] =
|
||||
&["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"];
|
||||
|
||||
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
|
||||
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
match id {
|
||||
"ec.time.timedifference" => time_difference(e, ctx, b),
|
||||
"ec.time.timedisplacement" => time_displacement(e, ctx, b),
|
||||
"ec.time.ccforcemotionblur" => {
|
||||
let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize;
|
||||
let angle = if ctx.params.b("overrideShutterAngle") { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let span = angle.clamp(0.0, 3600.0) / 360.0 / ctx.fps();
|
||||
mean_of(e, ctx, b, &(0..n).map(|i| ctx.time + span * i as f64 / n as f64).collect::<Vec<_>>())
|
||||
}
|
||||
"ec.time.ccwidetime" => {
|
||||
let fwd = ctx.params.f("forwardSteps").round().clamp(0.0, 45.0) as i64;
|
||||
let back = ctx.params.f("backwardSteps").round().clamp(0.0, 45.0) as i64;
|
||||
let fd = 1.0 / ctx.fps();
|
||||
mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::<Vec<_>>())
|
||||
}
|
||||
"ec.time.pixelmotionblur" => {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(e, ctx, b, ×)
|
||||
}
|
||||
"ec.time.timewarp" => timewarp(e, ctx, b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn kernel(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage {
|
||||
let out = e.scratch(src.width, src.height);
|
||||
e.pixels(entry, p, src, aux, &out, data);
|
||||
out
|
||||
}
|
||||
|
||||
/// time_fx::average: Σ frame × weight, one frame per pass in the CPU's order.
|
||||
fn average(e: &mut Enc, frames: &[GpuImage], weights: &[f32]) -> Option<GpuImage> {
|
||||
let first = frames.first()?;
|
||||
let mut acc = e.zeros(first.width, first.height);
|
||||
for (f, &k) in frames.iter().zip(weights) {
|
||||
let mut p = Params::default();
|
||||
p.f[0][0] = k;
|
||||
acc = kernel(e, "ftm_acc", &p, &acc, Some(f), None);
|
||||
}
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
fn on_grid(img: GpuImage, grid: &Buf) -> GBuf {
|
||||
GBuf { img, offset: grid.offset, scale: grid.scale }
|
||||
}
|
||||
|
||||
/// time_fx::mean_of: the plain average of the layer at `times`.
|
||||
fn mean_of(e: &mut Enc, ctx: &EffectCtx, b: GBuf, times: &[f64]) -> Option<GBuf> {
|
||||
if times.is_empty() {
|
||||
return Some(b);
|
||||
}
|
||||
let Some((grid, imgs)) = effectcraft_effects::time_frames(ctx, times) else { return Some(b) };
|
||||
let frames = imgs.iter().map(|i| e.g.upload_image(i)).collect::<Option<Vec<_>>>()?;
|
||||
let k = 1.0 / times.len() as f32;
|
||||
let img = average(e, &frames, &vec![k; times.len()])?;
|
||||
Some(on_grid(img, &grid))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Time Difference
|
||||
|
||||
fn time_difference(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let Some(host) = ctx.env.host else { return Some(b) };
|
||||
let pr = ctx.params;
|
||||
let off = pr.f("timeOffset");
|
||||
let target_id = pr.get("targetLayer").and_then(Value::as_layer);
|
||||
let (grid, cur, target) = match target_id.and_then(|id| host.layer_at(id, ctx.env.comp_time + off, false)) {
|
||||
Some(other) => {
|
||||
let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time]) else { return Some(b) };
|
||||
let cur = imgs.pop().unwrap_or_default();
|
||||
let tgt = fit_layer(ctx, &Buf { img: cur.clone(), ..grid.clone() }, &other, true);
|
||||
(grid, cur, tgt)
|
||||
}
|
||||
None => {
|
||||
let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time, ctx.time + off]) else { return Some(b) };
|
||||
let tgt = imgs.pop().unwrap_or_default();
|
||||
let cur = imgs.pop().unwrap_or_default();
|
||||
(grid, cur, tgt)
|
||||
}
|
||||
};
|
||||
let (cur, target) = (e.g.upload_image(&cur)?, e.g.upload_image(&target)?);
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [pr.b("absoluteDifference") as u32, pr.e("alphaChannel"), 0, 0];
|
||||
p.f[0][0] = 1.0 + pr.f("contrast") as f32 / 25.0;
|
||||
let img = kernel(e, "ftm_difference", &p, &cur, Some(&target), None);
|
||||
Some(on_grid(img, &grid))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Time Displacement
|
||||
|
||||
fn time_displacement(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
if ctx.env.host.is_none() {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let max = pr.f("maxDisplacementTime");
|
||||
let res = pr.f("timeResolution").clamp(1.0, 999.0);
|
||||
let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time]) else { return Some(b) };
|
||||
let grid = Buf { img: imgs.pop().unwrap_or_default(), ..grid };
|
||||
let map = match ctx.layer_param("displacementMapLayer", true) {
|
||||
Some(o) => fit_layer(ctx, &grid, &o, pr.b("stretchMap")),
|
||||
None => grid.img.clone(),
|
||||
};
|
||||
// The CPU effect's per-pixel time step (luminance → −max..+max, quantised).
|
||||
let span = 2.0 * max.abs() * res;
|
||||
let step = if span > 63.0 { 2.0 * max.abs() / 63.0 } else { 1.0 / res };
|
||||
let idx: Vec<i32> = map
|
||||
.data
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (c, a) = unpremul(*p);
|
||||
let l = if a > 0.0 { effectcraft_color::luminance(c[0], c[1], c[2]) } else { 0.5 };
|
||||
(((l as f64 - 0.5) * 2.0 * max) / step).round() as i32
|
||||
})
|
||||
.collect();
|
||||
let mut keys = idx.clone();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
if keys == [0] {
|
||||
return Some(on_grid(e.g.upload_image(&grid.img)?, &grid));
|
||||
}
|
||||
let times: Vec<f64> = keys.iter().map(|k| ctx.time + *k as f64 * step).collect();
|
||||
let Some((fgrid, frames)) = effectcraft_effects::time_frames(ctx, ×) else { return Some(on_grid(e.g.upload_image(&grid.img)?, &grid)) };
|
||||
let dx = (fgrid.offset[0] - grid.offset[0]).round() as i32;
|
||||
let dy = (fgrid.offset[1] - grid.offset[1]).round() as i32;
|
||||
let which: Vec<u8> = idx.iter().flat_map(|k| (keys.binary_search(k).unwrap_or(0) as u32).to_le_bytes()).collect();
|
||||
let data = e.bytes(which);
|
||||
let (w, h) = (grid.img.width, grid.img.height);
|
||||
let mut acc = e.zeros(w, h);
|
||||
for (fi, f) in frames.iter().enumerate() {
|
||||
let frame = e.g.upload_image(f)?;
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [fi as u32, w, dx as u32, dy as u32];
|
||||
let out = e.scratch(w, h);
|
||||
e.pixels("ftm_displace", &p, &acc, Some(&frame), &out, Some(&data));
|
||||
acc = out;
|
||||
}
|
||||
Some(on_grid(acc, &grid))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Timewarp
|
||||
|
||||
fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
if ctx.env.host.is_none() {
|
||||
return Some(b);
|
||||
}
|
||||
// A Matte Layer splits foreground and background: the CPU's path.
|
||||
if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() {
|
||||
return None;
|
||||
}
|
||||
let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) };
|
||||
let mut up: Vec<Option<GpuImage>> = vec![None; plan.frames.len()];
|
||||
let mut frame = |e: &mut Enc, i: usize| -> Option<GpuImage> {
|
||||
if up[i].is_none() {
|
||||
up[i] = Some(e.g.upload_image(&plan.frames[i])?);
|
||||
}
|
||||
up[i].clone()
|
||||
};
|
||||
let mut imgs = vec![];
|
||||
for (step, _) in &plan.steps {
|
||||
let img = match *step {
|
||||
TwStep::Whole(i) => frame(e, i)?,
|
||||
TwStep::Mix(i, j, w) => {
|
||||
let (a, bb) = (frame(e, i)?, frame(e, j)?);
|
||||
let mut p = Params::default();
|
||||
p.f[0][0] = w;
|
||||
kernel(e, "ftm_mix", &p, &a, Some(&bb), None)
|
||||
}
|
||||
TwStep::Motion { a, b: bi, w, flow } => {
|
||||
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
|
||||
let pb = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[bi], &plan.pm))?;
|
||||
let f = &plan.flows[flow];
|
||||
let v: Vec<f32> = f.v.iter().flat_map(|q| [q[0], q[1]]).collect();
|
||||
let data = e.data(&v);
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [if f.v.is_empty() { 0 } else { f.cols as u32 }, f.rows as u32, plan.pm.extreme as u32, plan.pm.build_from_one as u32];
|
||||
p.f[0] = [f.block as f32, w, plan.pm.error_threshold, 0.0];
|
||||
kernel(e, "ftm_motion", &p, &pa, Some(&pb), Some(&data))
|
||||
}
|
||||
};
|
||||
imgs.push(img);
|
||||
}
|
||||
let img = if imgs.len() == 1 {
|
||||
imgs.pop()?
|
||||
} else {
|
||||
let weights: Vec<f32> = plan.steps.iter().map(|(_, w)| *w).collect();
|
||||
average(e, &imgs, &weights)?
|
||||
};
|
||||
Some(on_grid(img, &plan.grid))
|
||||
}
|
||||
|
|
@ -0,0 +1,595 @@
|
|||
//! GPU effects, transitions and perspective (kernels in `shaders/fx_transition.wgsl`, every
|
||||
//! entry point prefixed `ftr_`): Block Dissolve, CC Glass Wipe, CC Grid Wipe, CC Image Wipe,
|
||||
//! CC Jaws, CC Line Sweep, CC Radial ScaleWipe, CC Scale Wipe, CC Twister, CC WarpoMatic,
|
||||
//! Radial Shadow, CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC Spotlight, CC
|
||||
//! Environment and 3D Glasses, with the CPU effects' exact steps.
|
||||
//!
|
||||
//! Scalars (corner distances, projected extents, light vectors, rotation matrices) are computed
|
||||
//! on the CPU in f64 as the CPU effect does; Block Dissolve's block indices too (one per column
|
||||
//! and row sub-sample), so the hash sees the CPU's exact integers. Other layers (gradient,
|
||||
//! reveal, backside, environment, stereo views) are fetched and fitted on the CPU
|
||||
//! (`util::fit_layer`) and uploaded. Gradient planes are blurred with the CPU's
|
||||
//! `util::gauss_plane` steps (box passes, edges repeated). Card Wipe (a 3D card renderer
|
||||
//! shared with Card Dance and Shatter) stays on the CPU.
|
||||
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::{GBuf, gaussian_blur};
|
||||
|
||||
/// Compute entry points in `fx_transition.wgsl`.
|
||||
pub(crate) const KERNELS: &[&str] = &[
|
||||
"ftr_plane",
|
||||
"ftr_mul",
|
||||
"ftr_under",
|
||||
"ftr_blocks",
|
||||
"ftr_grid",
|
||||
"ftr_radial_scale",
|
||||
"ftr_scale",
|
||||
"ftr_glass",
|
||||
"ftr_image_wipe",
|
||||
"ftr_jaws",
|
||||
"ftr_line_sweep",
|
||||
"ftr_twister",
|
||||
"ftr_react_contrast",
|
||||
"ftr_react_local",
|
||||
"ftr_warpo",
|
||||
"ftr_rshadow",
|
||||
"ftr_bender",
|
||||
"ftr_blob",
|
||||
"ftr_cylinder",
|
||||
"ftr_sphere",
|
||||
"ftr_spot",
|
||||
"ftr_env",
|
||||
"ftr_shift",
|
||||
"ftr_glasses",
|
||||
];
|
||||
|
||||
/// Effect ids implemented here.
|
||||
pub(crate) const IDS: &[&str] = &[
|
||||
"ec.transition.blockdissolve",
|
||||
"ec.transition.ccglasswipe",
|
||||
"ec.transition.ccgridwipe",
|
||||
"ec.transition.ccimagewipe",
|
||||
"ec.transition.ccjaws",
|
||||
"ec.transition.cclinesweep",
|
||||
"ec.transition.ccradialscalewipe",
|
||||
"ec.transition.ccscalewipe",
|
||||
"ec.transition.cctwister",
|
||||
"ec.transition.ccwarpomatic",
|
||||
"ec.perspective.radialshadow",
|
||||
"ec.distort.ccbender",
|
||||
"ec.distort.ccblobbylize",
|
||||
"ec.perspective.cccylinder",
|
||||
"ec.perspective.ccsphere",
|
||||
"ec.perspective.ccspotlight",
|
||||
"ec.perspective.ccenvironment",
|
||||
"ec.perspective.3dglasses",
|
||||
];
|
||||
|
||||
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
|
||||
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
match id {
|
||||
"ec.transition.blockdissolve" => block_dissolve(e, ctx, b),
|
||||
"ec.transition.ccglasswipe" => glass_wipe(e, ctx, b),
|
||||
"ec.transition.ccgridwipe" => grid_wipe(e, ctx, b),
|
||||
"ec.transition.ccimagewipe" => image_wipe(e, ctx, b),
|
||||
"ec.transition.ccjaws" => jaws(e, ctx, b),
|
||||
"ec.transition.cclinesweep" => line_sweep(e, ctx, b),
|
||||
"ec.transition.ccradialscalewipe" => radial_scale_wipe(e, ctx, b),
|
||||
"ec.transition.ccscalewipe" => scale_wipe(e, ctx, b),
|
||||
"ec.transition.cctwister" => twister(e, ctx, b),
|
||||
"ec.transition.ccwarpomatic" => warpomatic(e, ctx, b),
|
||||
"ec.perspective.radialshadow" => radial_shadow(e, ctx, b),
|
||||
"ec.distort.ccbender" => bender(e, ctx, b),
|
||||
"ec.distort.ccblobbylize" => blobbylize(e, ctx, b),
|
||||
"ec.perspective.cccylinder" => cylinder(e, ctx, b),
|
||||
"ec.perspective.ccsphere" => sphere(e, ctx, b),
|
||||
"ec.perspective.ccspotlight" => spotlight(e, ctx, b),
|
||||
"ec.perspective.ccenvironment" => environment(e, ctx, b),
|
||||
"ec.perspective.3dglasses" => glasses(e, ctx, b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/// A same-size per-pixel kernel over `src` (the buffer's size).
|
||||
fn kernel(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage {
|
||||
let out = e.scratch(src.width, src.height);
|
||||
e.pixels(entry, p, src, aux, &out, data);
|
||||
out
|
||||
}
|
||||
|
||||
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> Option<GBuf> {
|
||||
b.img = kernel(e, entry, p, &b.img, aux, data);
|
||||
Some(b)
|
||||
}
|
||||
|
||||
fn f4(v: [f64; 4]) -> [f32; 4] {
|
||||
v.map(|x| x as f32)
|
||||
}
|
||||
|
||||
fn rgb(c: [f32; 4], w: f32) -> [f32; 4] {
|
||||
[c[0], c[1], c[2], w]
|
||||
}
|
||||
|
||||
/// The CPU buffer geometry of `b` (no pixels: `fit_layer` reads only size, offset and scale).
|
||||
fn shape(b: &GBuf) -> Buf {
|
||||
Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale }
|
||||
}
|
||||
|
||||
/// The layer in layer parameter `id` fitted to `b` (`util::fit_layer`) and uploaded; `Some(None)`
|
||||
/// when none is chosen.
|
||||
fn fitted(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, masks_and_effects: bool, stretch: bool) -> Option<Option<GpuImage>> {
|
||||
match ctx.layer_param(id, masks_and_effects) {
|
||||
Some(o) => Some(Some(e.g.upload_image(&effectcraft_effects::util::fit_layer(ctx, &shape(b), &o, stretch))?)),
|
||||
None => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `util::layer_or_self`: the fitted layer, or the buffer itself.
|
||||
fn layer_or_self(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str) -> Option<GpuImage> {
|
||||
Some(fitted(e, ctx, b, id, true, false)?.unwrap_or_else(|| b.img.clone()))
|
||||
}
|
||||
|
||||
/// A plane (x) of `img`: `util::Src` index `src` times alpha (alpha itself unscaled); 11 is CC
|
||||
/// Environment's relief.
|
||||
fn plane(e: &mut Enc, img: &GpuImage, src: u32) -> GpuImage {
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = src;
|
||||
kernel(e, "ftr_plane", &p, img, None, None)
|
||||
}
|
||||
|
||||
/// transition2::prop_plane's property → `util::Src` index.
|
||||
fn prop_src(prop: u32) -> u32 {
|
||||
match prop {
|
||||
5 => 6,
|
||||
6 => 7,
|
||||
p => p.min(4),
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` as RGBA rows for the kernels' third image (`P.u[3]` = stride, width, height).
|
||||
fn rows(e: &mut Enc, p: &mut Params, img: &GpuImage) -> wgpu::Buffer {
|
||||
let (buf, stride) = e.image_rows(img);
|
||||
p.u[3] = [stride, img.width, img.height, 0];
|
||||
buf
|
||||
}
|
||||
|
||||
fn completion(ctx: &EffectCtx) -> f64 {
|
||||
(ctx.params.f("completion") / 100.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Block Dissolve (transition.rs)
|
||||
|
||||
fn block_dissolve(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let done = completion(ctx) as f32;
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let bw = (pr.f("blockWidth") * b.scale).max(1e-3);
|
||||
let bh = (pr.f("blockHeight") * b.scale).max(1e-3);
|
||||
let feather = pr.f("feather") * b.scale;
|
||||
let subs: Vec<f64> = if pr.b("softEdges") { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] };
|
||||
// Block indices per column / row sub-sample, as the CPU computes them (f64, then u32).
|
||||
let index =
|
||||
|n: u32, o: f64, size: f64| (0..n).flat_map(|i| subs.iter().map(move |s| (((i as f64 + s - o) / size).floor() as i64) as u32)).collect::<Vec<_>>();
|
||||
let cols = index(b.img.width, b.offset[0], bw);
|
||||
let table: Vec<u8> = cols.iter().chain(&index(b.img.height, b.offset[1], bh)).flat_map(|v| v.to_le_bytes()).collect();
|
||||
let data = e.bytes(table);
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [subs.len() as u32, ctx.seed ^ 0xb10c, cols.len() as u32, 0];
|
||||
p.f[0][0] = done;
|
||||
let mut mask = kernel(e, "ftr_blocks", &p, &b.img, None, Some(&data));
|
||||
if feather > 0.0 {
|
||||
mask = gaussian_blur(e, &mask, feather * 0.5, feather * 0.5, true);
|
||||
}
|
||||
run(e, "ftr_mul", &Params::default(), b, Some(&mask), None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Grid Wipe / Radial ScaleWipe / Scale Wipe (transition.rs)
|
||||
|
||||
/// transition::max_corner_dist.
|
||||
fn max_corner_dist(b: &GBuf, c: (f64, f64), metric: impl Fn(f64, f64) -> f64) -> f64 {
|
||||
let (w, h) = (b.img.width as f64, b.img.height as f64);
|
||||
[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| metric(x - c.0, y - c.1)).fold(1e-6, f64::max)
|
||||
}
|
||||
|
||||
fn grid_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let done = completion(ctx);
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let rot = pr.f("rotation").to_radians();
|
||||
let border = pr.f("border").max(0.0) * b.scale;
|
||||
let tiles = pr.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = pr.e("shape");
|
||||
let cs = (ctx.layer_size[0] * b.scale / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(&b, c, |dx, dy| metric(dx * co + dy * s, -dx * s + dy * co));
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [shape, pr.b("reverse") as u32, 0, 0];
|
||||
p.f[0] = f4([c.0, c.1, s, co]);
|
||||
p.f[1] = f4([cs, maxd, done, border]);
|
||||
run(e, "ftr_grid", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn radial_scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let done = completion(ctx);
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let maxd = max_corner_dist(&b, c, f64::hypot);
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.b("reverse") as u32;
|
||||
p.f[0] = f4([c.0, c.1, maxd, done]);
|
||||
run(e, "ftr_radial_scale", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let stretch = ctx.params.f("stretch").max(0.0);
|
||||
if stretch <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let a = ctx.params.f("direction").to_radians();
|
||||
let mut p = Params::default();
|
||||
p.f[0] = f4([c.0, c.1, a.sin(), -a.cos()]);
|
||||
p.f[1][0] = stretch as f32;
|
||||
run(e, "ftr_scale", &p, b, None, None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Glass Wipe / Image Wipe / WarpoMatic (transition2.rs)
|
||||
|
||||
fn glass_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let c = completion(ctx) as f32;
|
||||
if c <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let gimg = layer_or_self(e, ctx, &b, "gradientLayer")?;
|
||||
let mut g = plane(e, &gimg, 4);
|
||||
let sm = pr.f("softness") * b.scale * 0.2;
|
||||
if sm > 0.05 {
|
||||
g = gaussian_blur(e, &g, sm, sm, true);
|
||||
}
|
||||
let soft = (pr.f("softness") / 100.0) as f32;
|
||||
let rev = fitted(e, ctx, &b, "layerToReveal", true, false)?;
|
||||
let mut p = Params::default();
|
||||
let data = rev.as_ref().map(|r| rows(e, &mut p, r));
|
||||
p.u[0][0] = rev.is_some() as u32;
|
||||
p.f[0] = [c, soft * 0.5 + 0.01, (pr.f("displacementAmount") * b.scale * 4.0) as f32, 0.0];
|
||||
run(e, "ftr_glass", &p, b, Some(&g), data.as_ref())
|
||||
}
|
||||
|
||||
fn image_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let c = completion(ctx) as f32;
|
||||
if c <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let gimg = layer_or_self(e, ctx, &b, "layer")?;
|
||||
let mut g = plane(e, &gimg, prop_src(pr.e("property")));
|
||||
let bl = pr.f("blur") * b.scale * 0.5;
|
||||
if bl > 0.05 {
|
||||
g = gaussian_blur(e, &g, bl, bl, true);
|
||||
}
|
||||
let mut w = (pr.f("borderSoftness") / 100.0) as f32 * 0.5;
|
||||
if pr.b("autoSoftness") {
|
||||
w = w.max(0.02);
|
||||
}
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = pr.b("inverseGradient") as u32;
|
||||
p.f[0] = [c, w, 0.0, 0.0];
|
||||
run(e, "ftr_image_wipe", &p, b, Some(&g), None)
|
||||
}
|
||||
|
||||
fn warpomatic(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let c = completion(ctx) as f32;
|
||||
if c <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let rimg = layer_or_self(e, ctx, &b, "reactorLayer")?;
|
||||
let mut m = plane(e, &rimg, 4);
|
||||
let sm = pr.f("smoothness") * b.scale * 0.5;
|
||||
if sm > 0.05 {
|
||||
m = gaussian_blur(e, &m, sm, sm, true);
|
||||
}
|
||||
m = match pr.e("reactor") {
|
||||
1 => {
|
||||
let mean = gaussian_blur(e, &m, 8.0 * b.scale, 8.0 * b.scale, true);
|
||||
kernel(e, "ftr_react_contrast", &Params::default(), &m, Some(&mean), None)
|
||||
}
|
||||
2 => kernel(e, "ftr_react_local", &Params::default(), &m, Some(&m), None),
|
||||
_ => m,
|
||||
};
|
||||
let rev = fitted(e, ctx, &b, "layerToReveal", true, false)?;
|
||||
let mut p = Params::default();
|
||||
let data = rev.as_ref().map(|r| rows(e, &mut p, r));
|
||||
p.u[0] = [pr.e("warpDirection"), rev.is_some() as u32, 0, 0];
|
||||
p.f[0] = [c, (pr.f("blendSpan") / 100.0) as f32 * 0.5, (pr.f("warpAmount") * b.scale) as f32 * 20.0, 0.0];
|
||||
run(e, "ftr_warpo", &p, b, Some(&m), data.as_ref())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Jaws / Line Sweep / Twister (transition2.rs)
|
||||
|
||||
fn jaws(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let c = completion(ctx);
|
||||
if c <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let (cx, cy) = b.to_px(pr.v2("center"));
|
||||
let (sn, cs) = pr.f("direction").to_radians().sin_cos();
|
||||
let width = (pr.f("width") * b.scale).max(1.0);
|
||||
let amp = pr.f("height") / 100.0 * width * 0.5;
|
||||
let (bw, bh) = (b.img.width as f64, b.img.height as f64);
|
||||
let vmax = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|&(x, y)| (-(x - cx) * sn + (y - cy) * cs).abs()).fold(0.0, f64::max);
|
||||
let d = c * (vmax + 2.0 * amp + 2.0);
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = pr.e("shape");
|
||||
p.f[0] = f4([cx, cy, sn, cs]);
|
||||
p.f[1] = f4([width, amp, d, 0.0]);
|
||||
run(e, "ftr_jaws", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn line_sweep(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let c = completion(ctx);
|
||||
if c <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let (sn, cs) = pr.f("direction").to_radians().sin_cos();
|
||||
let thick = (pr.f("thickness") * b.scale).max(1.0);
|
||||
let slant = pr.f("slant") * b.scale;
|
||||
let (w, h) = (b.img.width as f64, b.img.height as f64);
|
||||
let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
|
||||
for (x, y) in [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] {
|
||||
let (u, v) = (x * cs + y * sn, -x * sn + y * cs);
|
||||
u0 = u0.min(u);
|
||||
u1 = u1.max(u);
|
||||
v0 = v0.min(v);
|
||||
v1 = v1.max(v);
|
||||
}
|
||||
let nstr = ((v1 - v0) / thick).ceil().max(1.0);
|
||||
let total = (u1 - u0) + slant.abs() * nstr + 1.0;
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [pr.b("flipDirection") as u32, (slant >= 0.0) as u32, 0, 0];
|
||||
p.f[0] = f4([sn, cs, v0, u0]);
|
||||
p.f[1] = f4([thick, slant.abs(), nstr, total]);
|
||||
p.f[2][0] = c as f32;
|
||||
run(e, "ftr_line_sweep", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn twister(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let c = completion(ctx);
|
||||
if c <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let (cx, cy) = b.to_px(pr.v2("center"));
|
||||
let (sn, cs) = pr.f("axis").to_radians().sin_cos();
|
||||
let half = (b.img.width as f64).hypot(b.img.height as f64) * 0.5;
|
||||
let back = fitted(e, ctx, &b, "backside", true, false)?;
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [back.is_some() as u32, pr.b("shading") as u32, 0, 0];
|
||||
p.f[0] = f4([cx, cy, sn, cs]);
|
||||
p.f[1] = f4([half, c, 0.0, 0.0]);
|
||||
run(e, "ftr_twister", &p, b, back.as_ref(), None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Radial Shadow / CC Cylinder / CC Sphere / CC Spotlight (perspective.rs)
|
||||
|
||||
fn radial_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let color = pr.color("color");
|
||||
let soft = pr.f("softness").max(0.0) * b.scale;
|
||||
let k = 1.0 + pr.f("projectionDistance").max(0.0) / 100.0;
|
||||
let lp0 = pr.v2("lightSource");
|
||||
if pr.b("resizeLayer") && !ctx.adjustment {
|
||||
let l = b.to_px(lp0);
|
||||
let (bw, bh) = (b.img.width as f64, b.img.height as f64);
|
||||
let grow = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|(cx, cy)| ((cx - l.0).hypot(cy - l.1)) * (k - 1.0)).fold(0.0, f64::max);
|
||||
b.pad(e, (grow + soft * 1.5).ceil() as u32 + 1)?;
|
||||
}
|
||||
let l = b.to_px(lp0);
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = (pr.e("render") == 1) as u32;
|
||||
p.f[0] = [l.0 as f32, l.1 as f32, k as f32, pr.f("opacity") as f32 / 100.0 * color[3]];
|
||||
p.f[1] = rgb(color, pr.f("colorInfluence") as f32 / 100.0);
|
||||
let mut sh = kernel(e, "ftr_rshadow", &p, &b.img, None, None);
|
||||
if soft > 0.0 {
|
||||
sh = gaussian_blur(e, &sh, soft * 0.5, soft * 0.5, false);
|
||||
}
|
||||
if !pr.b("shadowOnly") {
|
||||
sh = kernel(e, "ftr_under", &Params::default(), &sh, Some(&b.img), None);
|
||||
}
|
||||
b.img = sh;
|
||||
Some(b)
|
||||
}
|
||||
|
||||
fn norm3(v: [f64; 3]) -> [f64; 3] {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
/// perspective::Shading into f[3..7]; the layer rectangle into f[1].
|
||||
fn shading(ctx: &EffectCtx, b: &GBuf, p: &mut Params) {
|
||||
let pr = ctx.params;
|
||||
let (a, el) = (pr.f("lightDirection").to_radians(), pr.f("lightHeight").to_radians());
|
||||
let l = norm3([a.sin() * el.cos(), -a.cos() * el.cos(), el.sin()]);
|
||||
let hv = norm3([l[0], l[1], l[2] + 1.0]);
|
||||
p.f[1] = f4([b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale]);
|
||||
p.f[3] = [l[0] as f32, l[1] as f32, l[2] as f32, pr.f("lightIntensity") as f32 / 100.0];
|
||||
p.f[4] = rgb(pr.color("lightColor"), pr.f("ambient") as f32 / 100.0);
|
||||
p.f[5] = [pr.f("diffuse") as f32 / 100.0, pr.f("specular") as f32 / 100.0, 1.0 / (pr.f("roughness") as f32).max(0.001), 0.0];
|
||||
p.f[6] = [hv[0] as f32, hv[1] as f32, hv[2] as f32, 0.0];
|
||||
}
|
||||
|
||||
fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let (w, h) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
|
||||
let r = (pr.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
|
||||
let c = b.to_px(pr.v2("position"));
|
||||
let mut p = Params::default();
|
||||
shading(ctx, &b, &mut p);
|
||||
p.u[0][0] = pr.e("render");
|
||||
p.f[0] = f4([c.0, c.1, r, h]);
|
||||
p.f[2][0] = pr.f("rotation").to_radians() as f32;
|
||||
run(e, "ftr_cylinder", &p, b, None, None)
|
||||
}
|
||||
|
||||
/// perspective::rot_xyz (R = Rz · Ry · Rx).
|
||||
fn rot_xyz(rx: f64, ry: f64, rz: f64) -> [[f64; 3]; 3] {
|
||||
let (sx, cx) = rx.to_radians().sin_cos();
|
||||
let (sy, cy) = ry.to_radians().sin_cos();
|
||||
let (sz, cz) = rz.to_radians().sin_cos();
|
||||
let mx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
|
||||
let my = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
|
||||
let mz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
|
||||
let mul = |a: [[f64; 3]; 3], b: [[f64; 3]; 3]| {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
};
|
||||
mul(mz, mul(my, mx))
|
||||
}
|
||||
|
||||
fn sphere(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let r = (pr.f("radius") * b.scale).max(0.5);
|
||||
let c = b.to_px(pr.v2("offset"));
|
||||
let m = rot_xyz(pr.f("rotationX"), pr.f("rotationY"), pr.f("rotationZ"));
|
||||
let mut p = Params::default();
|
||||
shading(ctx, &b, &mut p);
|
||||
p.u[0][0] = pr.e("render");
|
||||
p.f[0] = f4([c.0, c.1, r, 0.0]);
|
||||
// Rows of M: the kernel computes Mᵀ · q as q.x·row0 + q.y·row1 + q.z·row2.
|
||||
for (i, row) in m.iter().enumerate() {
|
||||
p.f[7 + i] = f4([row[0], row[1], row[2], 0.0]);
|
||||
}
|
||||
run(e, "ftr_sphere", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn spotlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let (w, h) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
|
||||
let from = b.to_px(pr.v2("from"));
|
||||
let to = b.to_px(pr.v2("to"));
|
||||
let height = (pr.f("height") / 100.0 * w.max(h)).max(1e-3);
|
||||
let cone = pr.f("coneAngle").clamp(0.1, 89.9).to_radians();
|
||||
let soft = (pr.f("edgeSoftness") / 100.0).clamp(0.0, 1.0);
|
||||
let axis = norm3([to.0 - from.0, to.1 - from.1, -height]);
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = (pr.e("render") == 1) as u32;
|
||||
p.f[0] = [from.0 as f32, from.1 as f32, height as f32, pr.f("intensity") as f32 / 100.0];
|
||||
p.f[1] = f4([axis[0], axis[1], axis[2], cone * (1.0 - soft)]);
|
||||
p.f[2] = rgb(pr.color("color"), cone as f32);
|
||||
run(e, "ftr_spot", &p, b, None, None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Environment / 3D Glasses (perspective2.rs)
|
||||
|
||||
fn environment(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let (mut env, es, eo, esz) = match ctx.layer_param("environment", true) {
|
||||
Some(o) => (e.g.upload_image(&o.buf.img)?, o.buf.scale, o.buf.offset, o.size),
|
||||
None => (b.img.clone(), b.scale, b.offset, ctx.layer_size),
|
||||
};
|
||||
if pr.b("filterEnvironment") {
|
||||
env = gaussian_blur(e, &env, 2.0 * es, 2.0 * es, true);
|
||||
}
|
||||
let relief = plane(e, &b.img, 11);
|
||||
let soft = 1.5 * b.scale.max(0.25);
|
||||
let hmap = gaussian_blur(e, &relief, soft, soft, true);
|
||||
let mut p = Params::default();
|
||||
let data = rows(e, &mut p, &env);
|
||||
p.u[0][0] = pr.e("mapping");
|
||||
p.f[0] = f4([1.0 / b.scale.max(1e-6), pr.f("height") / 100.0 * 20.0, es, 0.0]);
|
||||
p.f[1] = f4([eo[0], eo[1], esz[0], esz[1]]);
|
||||
run(e, "ftr_env", &p, b, Some(&hmap), Some(&data))
|
||||
}
|
||||
|
||||
fn glasses(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let mut l = fitted(e, ctx, &b, "leftView", true, false)?.unwrap_or_else(|| b.img.clone());
|
||||
let mut r = fitted(e, ctx, &b, "rightView", true, false)?.unwrap_or_else(|| b.img.clone());
|
||||
if pr.b("leftRightSwap") {
|
||||
std::mem::swap(&mut l, &mut r);
|
||||
}
|
||||
let unit = if pr.e("units") == 1 { ctx.layer_size[0] / 100.0 } else { 1.0 } * b.scale;
|
||||
let conv = pr.f("sceneConvergence") * unit;
|
||||
let va = pr.f("verticalAlignment") * unit;
|
||||
let mut shift = |img: GpuImage, dx: f64, dy: f64| {
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return img;
|
||||
}
|
||||
let mut p = Params::default();
|
||||
p.f[0] = f4([dx, dy, 0.0, 0.0]);
|
||||
kernel(e, "ftr_shift", &p, &img, None, None)
|
||||
};
|
||||
let l = shift(l, conv * 0.5, 0.0);
|
||||
let r = shift(r, -conv * 0.5, va);
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = pr.e("view3d");
|
||||
p.f[0] = [b.img.width as f32, b.img.height as f32, (pr.f("balance") / 20.0).clamp(0.0, 1.0) as f32, 0.0];
|
||||
let out = kernel(e, "ftr_glasses", &p, &l, Some(&r), None);
|
||||
Some(GBuf { img: out, ..b })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Bender / CC Blobbylize (distort3.rs)
|
||||
|
||||
fn bender(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let amount = pr.f("amount") / 100.0;
|
||||
if amount == 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let top = b.to_px(pr.v2("top"));
|
||||
let base = b.to_px(pr.v2("base"));
|
||||
let (ax, ay) = (top.0 - base.0, top.1 - base.1);
|
||||
let len = (ax * ax + ay * ay).sqrt().max(1e-6);
|
||||
let (ux, uy) = (ax / len, ay / len);
|
||||
let reference = if pr.b("adjustToDistance") { len } else { ctx.layer_size[1] * b.scale };
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = pr.e("style");
|
||||
p.f[0] = f4([base.0, base.1, ux, uy]);
|
||||
p.f[1] = f4([len, -uy, ux, amount * reference]);
|
||||
run(e, "ftr_bender", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let soft = pr.f("softness").max(0.0) * b.scale;
|
||||
let blob = layer_or_self(e, ctx, &b, "blobLayer")?;
|
||||
let raw = plane(e, &blob, pr.e("property").min(10));
|
||||
let h = gaussian_blur(e, &raw, soft, soft, true);
|
||||
let lpos = b.to_px(pr.v2("lightPosition"));
|
||||
let lheight = pr.f("lightHeight");
|
||||
let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2);
|
||||
let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()];
|
||||
let span = (b.img.width.max(b.img.height) as f64).max(1.0);
|
||||
let mut p = Params::default();
|
||||
p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]);
|
||||
p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]);
|
||||
p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]);
|
||||
p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]);
|
||||
p.f[4] = rgb(pr.color("lightColor"), 0.0);
|
||||
run(e, "ftr_blob", &p, b, Some(&h), None)
|
||||
}
|
||||
|
|
@ -13,15 +13,18 @@
|
|||
//! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR,
|
||||
//! image-based light, shadow maps) and finish in compute kernels (`adv3d.wgsl`: supersampling
|
||||
//! resolve, motion-blur sub-samples, iris depth of field, encoding, compositing), see
|
||||
//! [`Accelerator::render_3d`] and `Renderer::prepare_adv_run`. Advanced 3D layers with blend
|
||||
//! modes, track mattes or Preserve Transparency still composite on the CPU (their scenes
|
||||
//! render here through [`Accelerator::render_3d`]).
|
||||
//! [`Accelerator::render_3d`] and `Renderer::prepare_adv_run`. Runs with layers on the 2D
|
||||
//! compositing path (blend modes, track mattes, Preserve Transparency:
|
||||
//! `Renderer::split_adv_run`) render each such layer alone, hide it behind the nearer main
|
||||
//! scene and composite it through the 2D path here too; environment backgrounds (Classic and
|
||||
//! Advanced 3D) draw their sky in a kernel (`Renderer::sky_draw`).
|
||||
//!
|
||||
//! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU
|
||||
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
|
||||
//! uploaded and read back once. Each family lives in its own module with its own WGSL file
|
||||
//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`,
|
||||
//! `fx_tone`, `fx_warp`); settings a kernel cannot match fall back to the CPU
|
||||
//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_light`, `fx_noise`,
|
||||
//! `fx_stylize`, `fx_text`, `fx_time`, `fx_tone`, `fx_transition`, `fx_warp`); settings a
|
||||
//! kernel cannot match fall back to the CPU
|
||||
//! (`catalog::gpu_supported`, or `None` from the family's `apply`).
|
||||
//!
|
||||
//! Working textures come from a pool (`context::Pool`): a frame allocates several full-frame
|
||||
|
|
@ -46,15 +49,20 @@ mod adv3d;
|
|||
mod bokeh;
|
||||
mod classic3d;
|
||||
mod context;
|
||||
pub mod deferred;
|
||||
mod effects;
|
||||
mod fx_color;
|
||||
mod fx_distort;
|
||||
mod fx_extra;
|
||||
mod fx_generate;
|
||||
mod fx_key;
|
||||
mod fx_light;
|
||||
mod fx_noise;
|
||||
mod fx_stylize;
|
||||
mod fx_text;
|
||||
mod fx_time;
|
||||
mod fx_tone;
|
||||
mod fx_transition;
|
||||
mod fx_warp;
|
||||
mod ops;
|
||||
mod particles;
|
||||
|
|
@ -105,6 +113,44 @@ impl Gpu {
|
|||
Gpu { ctx: Arc::new(ctx), auto: Default::default() }
|
||||
}
|
||||
|
||||
/// A device of its own without blocking, with deferred readbacks (a browser worker, see
|
||||
/// [`deferred`]); natively the same device works too (tests). `Err` says why there is none.
|
||||
pub async fn request_deferred() -> Result<Gpu, String> {
|
||||
let mut ctx = GpuContext::request().await?;
|
||||
ctx.set_deferred(true);
|
||||
Ok(Gpu::from_context(ctx))
|
||||
}
|
||||
|
||||
/// [`Gpu::request_deferred`] blocking (tests of the browser worker's path, natively).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn headless_deferred() -> Option<Gpu> {
|
||||
pollster::block_on(Gpu::request_deferred()).map_err(|e| log::info!("gpu: {e}")).ok()
|
||||
}
|
||||
|
||||
/// The deferred readback state, when readbacks are deferred.
|
||||
pub fn deferred(&self) -> Option<&Arc<deferred::Deferred>> {
|
||||
self.ctx.deferred()
|
||||
}
|
||||
|
||||
/// Resolves once no deferred readback is in flight (immediately without deferred readbacks).
|
||||
pub async fn settled(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.settled().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver finished readbacks without blocking (native; the browser delivers them itself).
|
||||
pub fn poll(&self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = self.ctx.device.poll(wgpu::PollType::Poll);
|
||||
}
|
||||
|
||||
/// `{passes, readbacks, inFlight}` of deferred rendering (diagnostics).
|
||||
pub fn deferred_stats(&self) -> Option<(u64, u64, usize)> {
|
||||
let d = self.ctx.deferred()?;
|
||||
Some((d.passes.load(std::sync::atomic::Ordering::Relaxed), d.readbacks.load(std::sync::atomic::Ordering::Relaxed), d.in_flight()))
|
||||
}
|
||||
|
||||
pub fn context(&self) -> &GpuContext {
|
||||
&self.ctx
|
||||
}
|
||||
|
|
@ -139,11 +185,50 @@ impl Gpu {
|
|||
if !self.ctx.can_readback() {
|
||||
return None;
|
||||
}
|
||||
if let Some(d) = self.ctx.deferred.clone() {
|
||||
return self.render_deferred(&d, r, comp, t);
|
||||
}
|
||||
let mut e = Enc::new(&self.ctx);
|
||||
let img = walk::render(&mut e, r, comp, t)?;
|
||||
e.download(&img)
|
||||
}
|
||||
|
||||
/// [`Gpu::render`] with deferred readbacks: the frame's readback is keyed by comp, time and
|
||||
/// render options; until it arrives (or while layer steps still miss) the pass gets a
|
||||
/// placeholder.
|
||||
fn render_deferred(&self, d: &Arc<deferred::Deferred>, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
let mut k = deferred::Key::new(2);
|
||||
k.u64(comp.0);
|
||||
k.u64(t.0 as u64);
|
||||
k.debug(&r.opts);
|
||||
let key = k.finish();
|
||||
let image = |rb: deferred::Readback| -> Option<Image> {
|
||||
let mut img = Image::new(rb.width, rb.height);
|
||||
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut img.data);
|
||||
(dst.len() == rb.bytes.len()).then(|| dst.copy_from_slice(&rb.bytes))?;
|
||||
Some(img)
|
||||
};
|
||||
match d.lookup(key) {
|
||||
deferred::Lookup::Ready(rb) => return rb.and_then(image),
|
||||
deferred::Lookup::InFlight => {
|
||||
d.miss();
|
||||
return Some(Image::new(1, 1));
|
||||
}
|
||||
deferred::Lookup::Absent => {}
|
||||
}
|
||||
let misses = d.misses();
|
||||
let mut e = Enc::new(&self.ctx);
|
||||
let img = walk::render(&mut e, r, comp, t)?;
|
||||
if d.misses() != misses {
|
||||
// Layer steps are still in flight: this pass's canvas is not the frame.
|
||||
return Some(Image::new(1, 1));
|
||||
}
|
||||
match e.read_keyed(&img, key, [0.0; 2], 1.0) {
|
||||
Some(rb) => rb.and_then(image),
|
||||
None => Some(Image::new(1, 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for all submitted GPU work (benchmarks).
|
||||
pub fn wait(&self) {
|
||||
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
|
|
@ -176,12 +261,32 @@ impl Accelerator for Gpu {
|
|||
}
|
||||
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
self.ctx.can_wait().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
}
|
||||
|
||||
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
|
||||
Some(&self.auto)
|
||||
}
|
||||
|
||||
fn frame_begin(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.frame_begin();
|
||||
}
|
||||
}
|
||||
|
||||
fn pass_begin(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.pass_begin();
|
||||
}
|
||||
}
|
||||
|
||||
fn pass_missed(&self) -> bool {
|
||||
self.ctx.deferred().is_some_and(|d| d.missed())
|
||||
}
|
||||
|
||||
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
|
||||
self.ctx.deferred().map(|d| d.gate())
|
||||
}
|
||||
}
|
||||
|
||||
impl effectcraft_effects::psim::ParticleSim for Gpu {
|
||||
|
|
@ -199,6 +304,8 @@ mod tests_adjust;
|
|||
#[cfg(test)]
|
||||
mod tests_adv3d;
|
||||
#[cfg(test)]
|
||||
mod tests_deferred;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_color;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_distort;
|
||||
|
|
@ -209,12 +316,20 @@ mod tests_fx_generate;
|
|||
#[cfg(test)]
|
||||
mod tests_fx_key;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_light;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_noise;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_stylize;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_text;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_time;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_tone;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_transition;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_warp;
|
||||
#[cfg(test)]
|
||||
mod tests_particles;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec<u8> {
|
|||
|
||||
/// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]).
|
||||
pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimParticle>> {
|
||||
if !g.can_readback() || req.steps > u32::MAX as u64 {
|
||||
if !g.can_wait() || req.steps > u32::MAX as u64 {
|
||||
return None;
|
||||
}
|
||||
let n = req.births.len() as u32;
|
||||
|
|
|
|||
|
|
@ -255,3 +255,24 @@ fn wire(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
let p = table[id.x];
|
||||
textureStore(out, vec2<i32>(i32(p.x), i32(p.y)), vec4<f32>(1.0));
|
||||
}
|
||||
|
||||
// A layer rendered on its own (color_in, finished) hidden where the run's main scene is nearer
|
||||
// (`adv::draw_run`): depth = its depth, src = the main scene's colour, radius = the main
|
||||
// scene's depth. Pixels it didn't draw (BIG) are never hidden, as ∞ compares on the CPU.
|
||||
// P.u0 = (width, height)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn occlude(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let i = id.y * w + id.x;
|
||||
let q = vec2<i32>(i32(id.x), i32(id.y));
|
||||
var p = textureLoad(color_in, q, 0);
|
||||
let z = depth[i];
|
||||
let mz = radius[i];
|
||||
if (z < BIG * 0.5 && mz < z - 0.01 * max(abs(z), 1.0)) {
|
||||
p *= 1.0 - clamp(src[i].w, 0.0, 1.0);
|
||||
}
|
||||
textureStore(out, q, p);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
// GPU effects, the CC light family: see src/fx_light.rs. CC Light Rays, CC Light Burst 2.5,
|
||||
// CC Light Sweep and CC Light Wipe, each mirroring the CPU effect operation for operation.
|
||||
// Coordinates are pixel centres relative to the effect's centre, so f32 keeps its precision.
|
||||
|
||||
fn flt_inside(p: vec2<i32>) -> bool {
|
||||
let d = out_dims();
|
||||
return p.x < d.x && p.y < d.y;
|
||||
}
|
||||
|
||||
fn flt_rel(p: vec2<i32>, c: vec2<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5) - c;
|
||||
}
|
||||
|
||||
// util::smoothstep.
|
||||
fn flt_smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
if (abs(e1 - e0) < 1e-9) {
|
||||
return select(1.0, 0.0, x < e0);
|
||||
}
|
||||
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
|
||||
// util::unpremul's colour.
|
||||
fn flt_unpremul(p: vec4<f32>) -> vec3<f32> {
|
||||
if (p.w > 1e-6) {
|
||||
return p.xyz / p.w;
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Rays (generate2::light_rays)
|
||||
// u[0] = (square, colour from source, transfer mode); f[0] = (cx, cy, k, gain);
|
||||
// f[1] = (radius, outer radius + 1e-3); f[2] = colour.
|
||||
|
||||
fn flt_ray_mask(q: vec2<f32>) -> f32 {
|
||||
let d = abs(q);
|
||||
var r = length(d);
|
||||
if (P.u[0].x != 0u) {
|
||||
r = max(d.x, d.y);
|
||||
}
|
||||
return 1.0 - flt_smoothstep(P.f[1].x, P.f[1].y, r);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn flt_rays(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!flt_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let c = P.f[0].xy;
|
||||
let rel = flt_rel(p, c);
|
||||
var acc = vec4<f32>(0.0);
|
||||
for (var i = 0u; i < 32u; i++) {
|
||||
let s = 1.0 - P.f[0].z * f32(i) / 32.0;
|
||||
let q = rel * s;
|
||||
let m = flt_ray_mask(q);
|
||||
if (m <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
acc += sample_bilinear(src, c.x + q.x, c.y + q.y) * m;
|
||||
}
|
||||
var ray = acc / 32.0 * P.f[0].w;
|
||||
if (P.u[0].y == 0u) {
|
||||
let l = luminance(flt_unpremul(ray)) * ray.w;
|
||||
ray = vec4<f32>(P.f[2].xyz * l, ray.w);
|
||||
}
|
||||
let la = min(ray.w, 1.0);
|
||||
let px = textureLoad(src, p, 0);
|
||||
var o: vec4<f32>;
|
||||
switch P.u[0].z {
|
||||
case 1u: {
|
||||
o = vec4<f32>(max(px.xyz, ray.xyz), max(px.w, la));
|
||||
}
|
||||
case 2u: {
|
||||
let r4 = vec4<f32>(ray.xyz, la);
|
||||
o = px + r4 - px * r4;
|
||||
}
|
||||
case 3u: {
|
||||
o = vec4<f32>(ray.xyz, la);
|
||||
}
|
||||
default: {
|
||||
let a = clamp(la, 0.0, 1.0);
|
||||
o = vec4<f32>(px.xyz + ray.xyz, clamp(px.w + a * (1.0 - px.w), 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Burst 2.5 (generate2::light_burst)
|
||||
// u[0] = (burst, set colour); f[0] = (cx, cy, ray length k, intensity); f[1] = colour.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn flt_burst(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!flt_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let c = P.f[0].xy;
|
||||
let rel = flt_rel(p, c);
|
||||
let mode = P.u[0].x;
|
||||
var acc = vec4<f32>(0.0);
|
||||
var wsum = 0.0;
|
||||
var best = vec4<f32>(0.0);
|
||||
var best_l = -1.0;
|
||||
for (var i = 0u; i < 32u; i++) {
|
||||
let f = f32(i) / 32.0;
|
||||
let s = 1.0 - P.f[0].z * f;
|
||||
let q = rel * s;
|
||||
let v = sample_bilinear(src, c.x + q.x, c.y + q.y);
|
||||
if (mode == 0u) {
|
||||
let l = luminance(v.xyz) + v.w * 1e-3;
|
||||
if (l > best_l) {
|
||||
best_l = l;
|
||||
best = v;
|
||||
}
|
||||
} else if (mode == 1u) {
|
||||
let w = 1.0 - f;
|
||||
acc += v * w;
|
||||
wsum += w;
|
||||
} else {
|
||||
acc += v;
|
||||
wsum += 1.0;
|
||||
}
|
||||
}
|
||||
var o = best;
|
||||
if (mode != 0u) {
|
||||
o = acc / max(wsum, 1e-6);
|
||||
}
|
||||
if (P.u[0].y != 0u) {
|
||||
let a = max(o.w, 0.0);
|
||||
let l = luminance(flt_unpremul(o)) * a;
|
||||
o = vec4<f32>(P.f[1].xyz * l, a);
|
||||
}
|
||||
let k = P.f[0].w;
|
||||
textureStore(out, p, vec4<f32>(o.xyz * k, clamp(o.w * k, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Sweep (generate2::light_sweep)
|
||||
// aux = the (softened) alpha plane in x. u[0] = (shape, reception mode); f[0] = (cx, cy, nx, ny);
|
||||
// f[1] = (half width, sweep, edge intensity); f[2] = light colour.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn flt_sweep(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!flt_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let px = textureLoad(src, p, 0);
|
||||
let rel = flt_rel(p, P.f[0].xy);
|
||||
let d = abs(rel.x * P.f[0].z + rel.y * P.f[0].w);
|
||||
let half = P.f[1].x;
|
||||
let mode = P.u[0].y;
|
||||
var band: f32;
|
||||
switch P.u[0].x {
|
||||
case 0u: {
|
||||
band = max(1.0 - d / half, 0.0);
|
||||
}
|
||||
case 2u: {
|
||||
band = clamp(half - d + 0.5, 0.0, 1.0);
|
||||
}
|
||||
default: {
|
||||
band = flt_smoothstep(half, 0.0, d);
|
||||
}
|
||||
}
|
||||
if (band <= 0.0) {
|
||||
textureStore(out, p, select(px, vec4<f32>(0.0), mode == 2u));
|
||||
return;
|
||||
}
|
||||
let gx = tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x;
|
||||
let gy = tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x;
|
||||
let edge = min(sqrt(gx * gx + gy * gy) * 2.0, 1.0);
|
||||
let light = band * P.f[1].y + band * edge * P.f[1].z;
|
||||
let lc = P.f[2].xyz;
|
||||
let a = px.w;
|
||||
var o: vec4<f32>;
|
||||
if (mode == 1u) {
|
||||
let la = min(band * P.f[1].y, 1.0);
|
||||
o = vec4<f32>(px.xyz + lc * light, min(a + la * (1.0 - a), 1.0));
|
||||
} else if (mode == 2u) {
|
||||
let la = min(light * a, 1.0);
|
||||
o = vec4<f32>(lc * la, la);
|
||||
} else {
|
||||
o = vec4<f32>(px.xyz + lc * light * a, a);
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Wipe (transition::light_wipe)
|
||||
// u[0] = (shape, colour from source, reverse); f[0] = (cx, cy, sin, cos);
|
||||
// f[1] = (edge, band, intensity × ramp); f[2] = colour.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn flt_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!flt_inside(p)) {
|
||||
return;
|
||||
}
|
||||
var px = textureLoad(src, p, 0);
|
||||
let d = flt_rel(p, P.f[0].xy);
|
||||
let s = P.f[0].z;
|
||||
let co = P.f[0].w;
|
||||
let q = vec2<f32>(d.x * co + d.y * s, -d.x * s + d.y * co);
|
||||
var m: f32;
|
||||
switch P.u[0].x {
|
||||
case 0u: {
|
||||
m = abs(q.x);
|
||||
}
|
||||
case 2u: {
|
||||
m = max(abs(q.x), abs(q.y));
|
||||
}
|
||||
default: {
|
||||
m = length(q);
|
||||
}
|
||||
}
|
||||
let edge = P.f[1].x;
|
||||
let visible = select(m > edge, m < edge, P.u[0].z != 0u);
|
||||
if (!visible) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let z = (m - edge) / P.f[1].y;
|
||||
let g = exp(-(z * z)) * P.f[1].z;
|
||||
if (g > 0.0) {
|
||||
var lc = P.f[2].xyz;
|
||||
if (P.u[0].y != 0u) {
|
||||
lc = flt_unpremul(px);
|
||||
}
|
||||
px = vec4<f32>(px.xyz + lc * g * px.w, px.w);
|
||||
}
|
||||
textureStore(out, p, px);
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
// effectcraft-effects operation for operation. Every name here is prefixed `fxn_` (the
|
||||
// family files share one module).
|
||||
|
||||
const FXN_MAX: f32 = 3.40282347e38;
|
||||
// f32::MAX exactly (a decimal that rounds above it is rejected by browsers' WGSL compilers).
|
||||
const FXN_MAX: f32 = 3.4028234663852886e38;
|
||||
|
||||
fn fxn_pixel(gid: vec3<u32>) -> vec2<i32> {
|
||||
let dims = out_dims();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
// GPU effects, Numbers and Timecode: see src/fx_text.rs. The glyph coverage is rasterised on
|
||||
// the CPU (aux: fill in x, stroke ring in y, opacity applied); this kernel composites it as
|
||||
// textfx::draw_plan does: the layer or transparency, Timecode's box, then fill and stroke.
|
||||
// u[0] = (display options, keep the layer, box); f[0] = fill colour; f[1] = stroke colour;
|
||||
// f[2] = box (x0, x1, y0, y1); f[3] = (box colour, box opacity).
|
||||
|
||||
fn ftx_over(dst: vec4<f32>, s: vec4<f32>) -> vec4<f32> {
|
||||
return s + dst * (1.0 - s.w);
|
||||
}
|
||||
|
||||
// textfx::tint.
|
||||
fn ftx_tint(c: vec4<f32>, a0: f32) -> vec4<f32> {
|
||||
let a = clamp(a0 * c.w, 0.0, 1.0);
|
||||
return vec4<f32>(c.xyz * a, a);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftx_text(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
let dims = out_dims();
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
var o = vec4<f32>(0.0);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = textureLoad(src, p, 0);
|
||||
}
|
||||
if (P.u[0].z != 0u) {
|
||||
let r = P.f[2];
|
||||
let fy = f32(p.y) + 0.5;
|
||||
let cy = clamp(fy - r.z + 0.5, 0.0, 1.0) * clamp(r.w - fy + 0.5, 0.0, 1.0);
|
||||
if (cy > 0.0) {
|
||||
let fx = f32(p.x) + 0.5;
|
||||
let cx = clamp(fx - r.x + 0.5, 0.0, 1.0) * clamp(r.y - fx + 0.5, 0.0, 1.0);
|
||||
let k = cx * cy * P.f[3].w;
|
||||
if (k > 0.0) {
|
||||
o = ftx_over(o, vec4<f32>(P.f[3].xyz * k, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
let cov = textureLoad(aux, p, 0);
|
||||
let fill = ftx_tint(P.f[0], cov.x);
|
||||
let stroke = ftx_tint(P.f[1], cov.y);
|
||||
switch P.u[0].x {
|
||||
case 1u: {
|
||||
o = ftx_over(o, stroke);
|
||||
}
|
||||
case 2u: {
|
||||
o = ftx_over(ftx_over(o, stroke), fill);
|
||||
}
|
||||
case 3u: {
|
||||
o = ftx_over(ftx_over(o, fill), stroke);
|
||||
}
|
||||
default: {
|
||||
o = ftx_over(o, fill);
|
||||
}
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
// GPU effects, time: see src/fx_time.rs. The frames come from the effect host (fetched and
|
||||
// placed on one grid on the CPU, as Echo's); these kernels combine them as the CPU effects do.
|
||||
|
||||
fn ftm_inside(p: vec2<i32>) -> bool {
|
||||
let d = out_dims();
|
||||
return p.x < d.x && p.y < d.y;
|
||||
}
|
||||
|
||||
// util::unpremul: (straight colour, alpha).
|
||||
fn ftm_unpremul(p: vec4<f32>) -> vec4<f32> {
|
||||
if (p.w > 1e-6) {
|
||||
return vec4<f32>(p.xyz / p.w, p.w);
|
||||
}
|
||||
return vec4<f32>(0.0, 0.0, 0.0, max(p.w, 0.0));
|
||||
}
|
||||
|
||||
// Weighted sum, one frame per pass (time_fx::average): src + aux × f[0].x.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftm_acc(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftm_inside(p)) {
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, textureLoad(src, p, 0) + textureLoad(aux, p, 0) * P.f[0].x);
|
||||
}
|
||||
|
||||
// Cross-fade (raster::flow::mix): src + (aux − src) × f[0].x.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftm_mix(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftm_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let a = textureLoad(src, p, 0);
|
||||
textureStore(out, p, a + (textureLoad(aux, p, 0) - a) * P.f[0].x);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Time Difference (time_fx::time_difference)
|
||||
// src = the layer now, aux = the target. u[0] = (absolute difference, alpha channel);
|
||||
// f[0].x = contrast gain.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftm_difference(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftm_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let cu = ftm_unpremul(textureLoad(src, p, 0));
|
||||
let tu = ftm_unpremul(textureLoad(aux, p, 0));
|
||||
let c = cu.xyz;
|
||||
let d = tu.xyz;
|
||||
let ca = cu.w;
|
||||
let da = tu.w;
|
||||
let gain = P.f[0].x;
|
||||
let diff = c - d;
|
||||
var rgb = clamp(0.5 + diff * 0.5 * gain, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
if (P.u[0].x != 0u) {
|
||||
rgb = clamp(abs(diff) * gain, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
}
|
||||
let mx = max(max(rgb.x, rgb.y), rgb.z);
|
||||
let light = (mx + min(min(rgb.x, rgb.y), rgb.z)) * 0.5;
|
||||
var a: f32;
|
||||
switch P.u[0].y {
|
||||
case 1u: {
|
||||
a = da;
|
||||
}
|
||||
case 2u: {
|
||||
a = (ca + da) * 0.5;
|
||||
}
|
||||
case 3u: {
|
||||
a = max(ca, da);
|
||||
}
|
||||
case 4u: {
|
||||
a = 1.0;
|
||||
}
|
||||
case 5u: {
|
||||
a = light;
|
||||
}
|
||||
case 6u: {
|
||||
a = mx;
|
||||
}
|
||||
case 7u: {
|
||||
a = abs(ca - da) * gain;
|
||||
}
|
||||
case 8u: {
|
||||
a = clamp(abs(ca - da) * gain, 0.0, 1.0);
|
||||
rgb = vec3<f32>(1.0);
|
||||
}
|
||||
default: {
|
||||
a = ca;
|
||||
}
|
||||
}
|
||||
a = clamp(a, 0.0, 1.0);
|
||||
textureStore(out, p, vec4<f32>(rgb * a, a));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Time Displacement (time_fx::time_displacement)
|
||||
// One pass per sampled time: pixels whose frame index (data, u32 per pixel, row length u[0].y)
|
||||
// is u[0].x take frame aux at (x + dx, y + dy) (u[0].zw, i32), the others keep src.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftm_displace(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftm_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let k = bitcast<u32>(data[u32(p.y) * P.u[0].y + u32(p.x)]);
|
||||
if (k == P.u[0].x) {
|
||||
textureStore(out, p, tex_get(aux, p.x + bitcast<i32>(P.u[0].z), p.y + bitcast<i32>(P.u[0].w)));
|
||||
} else {
|
||||
textureStore(out, p, textureLoad(src, p, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Timewarp Pixel Motion (time_fx::timewarp_interpolate)
|
||||
// src = frame a, aux = frame b (both source-cropped); data = the motion vectors (x, y pairs).
|
||||
// u[0] = (columns, rows, extreme filtering, build from one image); f[0] = (vector spacing,
|
||||
// fraction w, error threshold).
|
||||
|
||||
// raster::flow::Flow::at.
|
||||
fn ftm_flow_at(x: f32, y: f32) -> vec2<f32> {
|
||||
let cols = P.u[0].x;
|
||||
let rows = P.u[0].y;
|
||||
if (cols == 0u || rows == 0u) {
|
||||
return vec2<f32>(0.0);
|
||||
}
|
||||
let b = P.f[0].x;
|
||||
let fx = clamp(x / b - 0.5, 0.0, f32(cols - 1u));
|
||||
let fy = clamp(y / b - 0.5, 0.0, f32(rows - 1u));
|
||||
let x0 = u32(floor(fx));
|
||||
let y0 = u32(floor(fy));
|
||||
let x1 = min(x0 + 1u, cols - 1u);
|
||||
let y1 = min(y0 + 1u, rows - 1u);
|
||||
let tx = fx - f32(x0);
|
||||
let ty = fy - f32(y0);
|
||||
let g00 = vec2<f32>(data[(y0 * cols + x0) * 2u], data[(y0 * cols + x0) * 2u + 1u]);
|
||||
let g10 = vec2<f32>(data[(y0 * cols + x1) * 2u], data[(y0 * cols + x1) * 2u + 1u]);
|
||||
let g01 = vec2<f32>(data[(y1 * cols + x0) * 2u], data[(y1 * cols + x0) * 2u + 1u]);
|
||||
let g11 = vec2<f32>(data[(y1 * cols + x1) * 2u], data[(y1 * cols + x1) * 2u + 1u]);
|
||||
let top = g00 + (g10 - g00) * tx;
|
||||
let bot = g01 + (g11 - g01) * tx;
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
fn ftm_sample(t: texture_2d<f32>, x: f32, y: f32) -> vec4<f32> {
|
||||
if (P.u[0].z != 0u) {
|
||||
return sample_bicubic(t, x, y);
|
||||
}
|
||||
return sample_bilinear_clamped(t, x, y);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftm_motion(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftm_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let cx = f32(p.x) + 0.5;
|
||||
let cy = f32(p.y) + 0.5;
|
||||
let f = ftm_flow_at(cx, cy);
|
||||
let w = P.f[0].y;
|
||||
var pa = ftm_sample(src, cx - w * f.x, cy - w * f.y);
|
||||
var pb = ftm_sample(aux, cx + (1.0 - w) * f.x, cy + (1.0 - w) * f.y);
|
||||
if (P.u[0].w != 0u) {
|
||||
textureStore(out, p, select(pb, pa, w < 0.5));
|
||||
return;
|
||||
}
|
||||
let d = abs(pa - pb);
|
||||
let err = max(max(d.x, d.y), max(d.z, d.w));
|
||||
let thr = P.f[0].z;
|
||||
if (thr > 0.0 && err > thr && thr < 1.0) {
|
||||
pa = textureLoad(src, p, 0);
|
||||
pb = textureLoad(aux, p, 0);
|
||||
}
|
||||
textureStore(out, p, pa + (pb - pa) * w);
|
||||
}
|
||||
|
|
@ -0,0 +1,883 @@
|
|||
// GPU effects, transitions and perspective: see src/fx_transition.rs. Block Dissolve, the CC
|
||||
// transitions (Glass Wipe, Grid Wipe, Image Wipe, Jaws, Line Sweep, Radial ScaleWipe, Scale
|
||||
// Wipe, Twister, WarpoMatic), Radial Shadow, CC Bender, CC Blobbylize, CC Cylinder, CC Sphere,
|
||||
// CC Spotlight, CC Environment and 3D Glasses, each mirroring the CPU effect operation for
|
||||
// operation. Coordinates are pixel centres relative to the effect's centre where it has one.
|
||||
|
||||
fn ftr_inside(p: vec2<i32>) -> bool {
|
||||
let d = out_dims();
|
||||
return p.x < d.x && p.y < d.y;
|
||||
}
|
||||
|
||||
fn ftr_centre(p: vec2<i32>) -> vec2<f32> {
|
||||
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5);
|
||||
}
|
||||
|
||||
// util::smoothstep.
|
||||
fn ftr_smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
if (abs(e1 - e0) < 1e-9) {
|
||||
return select(1.0, 0.0, x < e0);
|
||||
}
|
||||
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
|
||||
// util::unpremul's colour.
|
||||
fn ftr_unpremul(p: vec4<f32>) -> vec3<f32> {
|
||||
if (p.w > 1e-6) {
|
||||
return p.xyz / p.w;
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
|
||||
// The third image, copied into `data` as RGBA f32 rows: P.u[3] = (row stride, width, height).
|
||||
fn ftr_row(x: i32, y: i32) -> vec4<f32> {
|
||||
if (x < 0 || y < 0 || x >= i32(P.u[3].y) || y >= i32(P.u[3].z)) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
let i = (u32(y) * P.u[3].x + u32(x)) * 4u;
|
||||
return vec4<f32>(data[i], data[i + 1u], data[i + 2u], data[i + 3u]);
|
||||
}
|
||||
|
||||
fn ftr_row_clamped(x: i32, y: i32) -> vec4<f32> {
|
||||
return ftr_row(clamp(x, 0, i32(P.u[3].y) - 1), clamp(y, 0, i32(P.u[3].z) - 1));
|
||||
}
|
||||
|
||||
// Image::sample_bilinear on the third image.
|
||||
fn ftr_row_bilinear(x: f32, y: f32) -> vec4<f32> {
|
||||
let fx = x - 0.5;
|
||||
let fy = y - 0.5;
|
||||
let x0 = floor(fx);
|
||||
let y0 = floor(fy);
|
||||
let tx = fx - x0;
|
||||
let ty = fy - y0;
|
||||
let xi = i32(x0);
|
||||
let yi = i32(y0);
|
||||
let a = ftr_row(xi, yi);
|
||||
let b = ftr_row(xi + 1, yi);
|
||||
let c = ftr_row(xi, yi + 1);
|
||||
let d = ftr_row(xi + 1, yi + 1);
|
||||
let top = a + (b - a) * tx;
|
||||
let bot = c + (d - c) * tx;
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
// Image::sample_bilinear_clamped on the third image.
|
||||
fn ftr_row_bilinear_clamped(x: f32, y: f32) -> vec4<f32> {
|
||||
let fx = x - 0.5;
|
||||
let fy = y - 0.5;
|
||||
let x0 = floor(fx);
|
||||
let y0 = floor(fy);
|
||||
let tx = fx - x0;
|
||||
let ty = fy - y0;
|
||||
let xi = i32(x0);
|
||||
let yi = i32(y0);
|
||||
let a = ftr_row_clamped(xi, yi);
|
||||
let b = ftr_row_clamped(xi + 1, yi);
|
||||
let c = ftr_row_clamped(xi, yi + 1);
|
||||
let d = ftr_row_clamped(xi + 1, yi + 1);
|
||||
let top = a + (b - a) * tx;
|
||||
let bot = c + (d - c) * tx;
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
// Central differences of the aux plane (x), edges clamped (transition2::grad).
|
||||
fn ftr_grad(p: vec2<i32>) -> vec2<f32> {
|
||||
let gx = (tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x) * 0.5;
|
||||
let gy = (tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x) * 0.5;
|
||||
return vec2<f32>(gx, gy);
|
||||
}
|
||||
|
||||
// transition2::wipe_t.
|
||||
fn ftr_wipe_t(g: f32, c: f32, w0: f32) -> f32 {
|
||||
let w = max(w0, 1e-3);
|
||||
let edge = c * (1.0 + 2.0 * w) - w;
|
||||
return 1.0 - ftr_smoothstep(edge - w, edge + w, g);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- planes
|
||||
|
||||
// A plane (x) from the source: u[0].x = util::Src index (Red, Green, Blue, Alpha, Luminance,
|
||||
// Hue, Lightness, Saturation, Full, Half, Off) times alpha except for Alpha itself
|
||||
// (transition2::prop_plane, CC Blobbylize); 11 = CC Environment's relief.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_plane(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let px = textureLoad(src, p, 0);
|
||||
let c = ftr_unpremul(px);
|
||||
let a = max(px.w, 0.0);
|
||||
var v: f32;
|
||||
switch P.u[0].x {
|
||||
case 0u: {
|
||||
v = c.x;
|
||||
}
|
||||
case 1u: {
|
||||
v = c.y;
|
||||
}
|
||||
case 2u: {
|
||||
v = c.z;
|
||||
}
|
||||
case 3u: {
|
||||
v = a;
|
||||
}
|
||||
case 4u: {
|
||||
v = luminance(c);
|
||||
}
|
||||
case 5u: {
|
||||
v = rgb_to_hsl(c).x;
|
||||
}
|
||||
case 6u: {
|
||||
v = rgb_to_hsl(c).z;
|
||||
}
|
||||
case 7u: {
|
||||
v = rgb_to_hsl(c).y;
|
||||
}
|
||||
case 8u: {
|
||||
v = 1.0;
|
||||
}
|
||||
case 9u: {
|
||||
v = 0.5;
|
||||
}
|
||||
case 11u: {
|
||||
v = luminance(c) * 0.5 + 0.5;
|
||||
}
|
||||
default: {
|
||||
v = 0.0;
|
||||
}
|
||||
}
|
||||
if (P.u[0].x != 3u) {
|
||||
v *= a;
|
||||
}
|
||||
textureStore(out, p, vec4<f32>(v, 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// src × aux.x (Image::mul_mask).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_mul(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, textureLoad(src, p, 0) * textureLoad(aux, p, 0).x);
|
||||
}
|
||||
|
||||
// aux + src × (1 − aux.a): src composited under aux.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_under(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let o = textureLoad(aux, p, 0);
|
||||
textureStore(out, p, o + textureLoad(src, p, 0) * (1.0 - o.w));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Block Dissolve (transition::block_dissolve)
|
||||
// The kept-block mask (x). u[0] = (sub-samples per axis, seed, column table length);
|
||||
// f[0].x = completion; data = block columns (width × n, u32) then block rows (height × n).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_blocks(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let n = P.u[0].x;
|
||||
var hits = 0u;
|
||||
for (var j = 0u; j < n; j++) {
|
||||
let by = bitcast<u32>(data[P.u[0].z + u32(p.y) * n + j]);
|
||||
for (var i = 0u; i < n; i++) {
|
||||
let bx = bitcast<u32>(data[u32(p.x) * n + i]);
|
||||
if (hash_noise(bx, by, P.u[0].y) >= P.f[0].x) {
|
||||
hits += 1u;
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(out, p, vec4<f32>(f32(hits) / f32(n * n), 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Grid Wipe (transition::grid_wipe)
|
||||
// u[0] = (shape, reverse); f[0] = (cx, cy, sin, cos); f[1] = (cell size, max distance,
|
||||
// completion, border).
|
||||
|
||||
fn ftr_metric(shape: u32, d: vec2<f32>) -> f32 {
|
||||
switch shape {
|
||||
case 0u: {
|
||||
return abs(d.x);
|
||||
}
|
||||
case 2u: {
|
||||
return max(abs(d.x), abs(d.y));
|
||||
}
|
||||
default: {
|
||||
return length(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_grid(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let px = textureLoad(src, p, 0);
|
||||
let d = ftr_centre(p) - P.f[0].xy;
|
||||
let s = P.f[0].z;
|
||||
let co = P.f[0].w;
|
||||
let q = vec2<f32>(d.x * co + d.y * s, -d.x * s + d.y * co);
|
||||
let cs = P.f[1].x;
|
||||
let cc = (floor(q / cs) + 0.5) * cs;
|
||||
var o = clamp(ftr_metric(P.u[0].x, cc) / P.f[1].y, 0.0, 1.0);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let spread = 0.25;
|
||||
let pr = clamp((P.f[1].z * (1.0 + spread) - o) / spread, 0.0, 1.0);
|
||||
if (pr <= 0.0) {
|
||||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - pr);
|
||||
let m = max(abs(q.x - cc.x), abs(q.y - cc.y));
|
||||
var k = select(0.0, 1.0, m < half);
|
||||
if (P.f[1].w > 0.0) {
|
||||
k = clamp((half - m) / P.f[1].w + 0.5, 0.0, 1.0);
|
||||
}
|
||||
textureStore(out, p, px * k);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Radial ScaleWipe / CC Scale Wipe (transition.rs)
|
||||
// Radial: u[0].x = reverse; f[0] = (cx, cy, max distance, completion).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_radial_scale(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let c = P.f[0].xy;
|
||||
let maxd = P.f[0].z;
|
||||
let done = P.f[0].w;
|
||||
let d = ftr_centre(p) - c;
|
||||
let r = length(d);
|
||||
var o = vec4<f32>(0.0);
|
||||
if (P.u[0].x != 0u) {
|
||||
let lim = (1.0 - done) * maxd;
|
||||
if (!(r >= lim || lim <= 1e-9)) {
|
||||
let k = maxd / lim;
|
||||
o = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k);
|
||||
}
|
||||
} else {
|
||||
let hole = done * maxd;
|
||||
if (!(r <= hole || maxd - hole <= 1e-9)) {
|
||||
let rs = (r - hole) * maxd / (maxd - hole);
|
||||
o = sample_bilinear(src, c.x + d.x / r * rs, c.y + d.y / r * rs);
|
||||
}
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
||||
// Scale Wipe: f[0] = (cx, cy, direction x, direction y); f[1].x = stretch.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_scale(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let f = ftr_centre(p);
|
||||
let dir = P.f[0].zw;
|
||||
let u = dot(f - P.f[0].xy, dir);
|
||||
var q = f;
|
||||
if (u > 0.0) {
|
||||
let back = u - u / (1.0 + P.f[1].x);
|
||||
q = f - dir * back;
|
||||
}
|
||||
textureStore(out, p, sample_bilinear_clamped(src, q.x, q.y));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Glass Wipe (transition2::glass_wipe)
|
||||
// aux = the gradient plane (x); data = the layer to reveal (rows). u[0].x = has reveal;
|
||||
// f[0] = (completion, band half-width, displacement).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_glass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let t = ftr_wipe_t(textureLoad(aux, p, 0).x, P.f[0].x, P.f[0].y);
|
||||
let band = 4.0 * t * (1.0 - t);
|
||||
let g = ftr_grad(p);
|
||||
let f = ftr_centre(p);
|
||||
let o = g * P.f[0].z * band * 10.0;
|
||||
var a = textureLoad(src, p, 0);
|
||||
var r = vec4<f32>(0.0);
|
||||
if (band > 1e-4) {
|
||||
a = sample_bilinear(src, f.x + o.x, f.y + o.y);
|
||||
if (P.u[0].x != 0u) {
|
||||
r = ftr_row_bilinear(f.x - o.x, f.y - o.y);
|
||||
}
|
||||
} else if (P.u[0].x != 0u) {
|
||||
r = ftr_row(p.x, p.y);
|
||||
}
|
||||
textureStore(out, p, a + (r - a) * t);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Image Wipe (transition2::image_wipe)
|
||||
// aux = the gradient plane (x). u[0].x = inverse; f[0] = (completion, band half-width).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_image_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
var g = textureLoad(aux, p, 0).x;
|
||||
if (P.u[0].x != 0u) {
|
||||
g = 1.0 - g;
|
||||
}
|
||||
let k = 1.0 - ftr_wipe_t(g, P.f[0].x, P.f[0].y);
|
||||
textureStore(out, p, textureLoad(src, p, 0) * k);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Jaws (transition2::jaws)
|
||||
// u[0].x = shape; f[0] = (cx, cy, sin, cos); f[1] = (tooth width, amplitude, travel).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_jaws(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let c = P.f[0].xy;
|
||||
let sn = P.f[0].z;
|
||||
let cs = P.f[0].w;
|
||||
let width = P.f[1].x;
|
||||
let amp = P.f[1].y;
|
||||
let dd = P.f[1].z;
|
||||
let d = ftr_centre(p) - c;
|
||||
let u = d.x * cs + d.y * sn;
|
||||
let v = -d.x * sn + d.y * cs;
|
||||
let ph = fract_euclid(u / width);
|
||||
var f: f32;
|
||||
switch P.u[0].x {
|
||||
case 0u: {
|
||||
f = amp * (1.0 - 4.0 * abs(ph - 0.5));
|
||||
}
|
||||
case 1u: {
|
||||
f = amp * clamp((1.0 - 4.0 * abs(ph - 0.5)) * 2.0, -1.0, 1.0);
|
||||
}
|
||||
case 2u: {
|
||||
f = select(-amp, amp, ph < 0.5);
|
||||
}
|
||||
default: {
|
||||
f = amp * sin(ph * 6.283185307179586);
|
||||
}
|
||||
}
|
||||
var vs = 0.0;
|
||||
var hit = true;
|
||||
if (v + dd < f) {
|
||||
vs = v + dd;
|
||||
} else if (v - dd >= f) {
|
||||
vs = v - dd;
|
||||
} else {
|
||||
hit = false;
|
||||
}
|
||||
var o = vec4<f32>(0.0);
|
||||
if (hit) {
|
||||
o = sample_bilinear(src, u * cs - vs * sn + c.x, u * sn + vs * cs + c.y);
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Line Sweep (transition2::line_sweep)
|
||||
// u[0] = (flip, slant ≥ 0); f[0] = (sin, cos, v0, u0); f[1] = (thickness, |slant|, strips,
|
||||
// total travel); f[2].x = completion.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_line_sweep(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let f = ftr_centre(p);
|
||||
let sn = P.f[0].x;
|
||||
let cs = P.f[0].y;
|
||||
let u = f.x * cs + f.y * sn;
|
||||
let v = -f.x * sn + f.y * cs;
|
||||
let nstr = P.f[1].z;
|
||||
var k = floor((v - P.f[0].z) / P.f[1].x);
|
||||
if (P.u[0].x != 0u) {
|
||||
k = nstr - 1.0 - k;
|
||||
}
|
||||
let stagger = select(nstr - 1.0 - k, k, P.u[0].y != 0u);
|
||||
let front = P.f[0].w + P.f[2].x * P.f[1].w - P.f[1].y * stagger;
|
||||
textureStore(out, p, select(textureLoad(src, p, 0), vec4<f32>(0.0), u < front));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Twister (transition2::twister)
|
||||
// aux = the backside layer (u[0].x = has one); u[0].y = shading; f[0] = (cx, cy, sin, cos);
|
||||
// f[1] = (half diagonal, completion).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_twister(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let c = P.f[0].xy;
|
||||
let sn = P.f[0].z;
|
||||
let cs = P.f[0].w;
|
||||
let d = ftr_centre(p) - c;
|
||||
let u = d.x * cs + d.y * sn;
|
||||
let v = -d.x * sn + d.y * cs;
|
||||
let s01 = clamp((u / P.f[1].x) * 0.5 + 0.5, 0.0, 1.0);
|
||||
let th = 3.141592653589793 * clamp(P.f[1].y * 2.0 - s01, 0.0, 1.0);
|
||||
let k = cos(th);
|
||||
if (abs(k) < 1e-4) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let vs = v / k;
|
||||
var o: vec4<f32>;
|
||||
if (k > 0.0 || P.u[0].x == 0u) {
|
||||
o = sample_bilinear(src, u * cs - vs * sn + c.x, u * sn + vs * cs + c.y);
|
||||
} else {
|
||||
o = sample_bilinear(aux, u * cs + vs * sn + c.x, u * sn - vs * cs + c.y);
|
||||
}
|
||||
if (P.u[0].y != 0u) {
|
||||
let l = 0.4 + 0.6 * abs(k);
|
||||
o = vec4<f32>(o.xyz * l, o.w);
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC WarpoMatic (transition2::warpomatic)
|
||||
|
||||
// Reactor "Contrast Differences": src = the plane, aux = its wide mean.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_react_contrast(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let a = textureLoad(src, p, 0).x;
|
||||
let b = textureLoad(aux, p, 0).x;
|
||||
textureStore(out, p, vec4<f32>(min(abs(a - b) * 4.0, 1.0), 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// Reactor "Local Differences": aux = the plane; its gradient magnitude.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_react_local(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let g = ftr_grad(p);
|
||||
textureStore(out, p, vec4<f32>(min(sqrt(g.x * g.x + g.y * g.y) * 8.0, 1.0), 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// aux = the reactor plane (x); data = the layer to reveal (rows). u[0] = (warp direction,
|
||||
// has reveal); f[0] = (completion, blend span, warp amount).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_warpo(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let q = ftr_wipe_t(textureLoad(aux, p, 0).x, P.f[0].x, P.f[0].y);
|
||||
let g = ftr_grad(p);
|
||||
let f = ftr_centre(p);
|
||||
let amt = P.f[0].z;
|
||||
let o = g * amt * q;
|
||||
var i = g * amt * (1.0 - q);
|
||||
if (P.u[0].x == 1u) {
|
||||
i = -i;
|
||||
} else if (P.u[0].x == 2u) {
|
||||
i = vec2<f32>(-i.y, i.x);
|
||||
}
|
||||
var a = vec4<f32>(0.0);
|
||||
if (q < 1.0) {
|
||||
a = sample_bilinear(src, f.x + o.x, f.y + o.y);
|
||||
}
|
||||
var inc = vec4<f32>(0.0);
|
||||
if (P.u[0].y != 0u && q > 0.0) {
|
||||
inc = ftr_row_bilinear(f.x + i.x, f.y + i.y);
|
||||
}
|
||||
textureStore(out, p, a + (inc - a) * q);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Radial Shadow (perspective::radial_shadow)
|
||||
// The shadow before its blur. u[0].x = glass edge; f[0] = (light x, y, projection k,
|
||||
// opacity × colour alpha); f[1] = (colour, colour influence).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_rshadow(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let l = P.f[0].xy;
|
||||
let q = ftr_centre(p);
|
||||
let s = sample_bilinear(src, l.x + (q.x - l.x) / P.f[0].z, l.y + (q.y - l.y) / P.f[0].z);
|
||||
let sc = ftr_unpremul(s);
|
||||
let sa = max(s.w, 0.0);
|
||||
var a = sa;
|
||||
if (P.u[0].x != 0u) {
|
||||
a = clamp(sa * (1.0 - sa) * 4.0, 0.0, 1.0);
|
||||
}
|
||||
a *= P.f[0].w;
|
||||
let col = P.f[1].xyz;
|
||||
let c = col + (sc - col) * P.f[1].w;
|
||||
textureStore(out, p, vec4<f32>(c * a, a));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Bender (distort3::cc_bender)
|
||||
// u[0].x = style; f[0] = (base x, y, axis x, y); f[1] = (axis length, normal x, y, amount ×
|
||||
// reference).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_bender(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let f = ftr_centre(p);
|
||||
let r = f - P.f[0].xy;
|
||||
let t = clamp(dot(r, P.f[0].zw) / P.f[1].x, 0.0, 1.0);
|
||||
let pi = 3.141592653589793;
|
||||
var b: f32;
|
||||
switch P.u[0].x {
|
||||
case 1u: {
|
||||
b = 0.5 * sin(pi * t) * t;
|
||||
}
|
||||
case 2u: {
|
||||
b = 0.5 * t;
|
||||
}
|
||||
case 3u: {
|
||||
b = 0.25 * (1.0 - cos(pi * t)) - 0.1 * sin(2.0 * pi * t);
|
||||
}
|
||||
default: {
|
||||
b = 0.5 * t * t;
|
||||
}
|
||||
}
|
||||
let d = P.f[1].w * b;
|
||||
textureStore(out, p, sample_bilinear(src, f.x - P.f[1].y * d, f.y - P.f[1].z * d));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize)
|
||||
// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient);
|
||||
// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height,
|
||||
// point light); f[3] = distant light direction; f[4] = light colour.
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let depth = P.f[0].x;
|
||||
let cut = P.f[0].y;
|
||||
let hv = textureLoad(aux, p, 0).x;
|
||||
let g = ftr_grad(p);
|
||||
let f = ftr_centre(p);
|
||||
let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0);
|
||||
let c = ftr_unpremul(s);
|
||||
let a = max(s.w, 0.0);
|
||||
let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0);
|
||||
let alpha = max(a, s.w) * m;
|
||||
if (alpha <= 0.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let n = normalize(vec3<f32>(-g.x * depth, -g.y * depth, 1.0));
|
||||
var l = P.f[3].xyz;
|
||||
if (P.f[2].w != 0.0) {
|
||||
l = normalize(vec3<f32>(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z));
|
||||
}
|
||||
let intensity = P.f[0].z;
|
||||
let ndl = max(dot(n, l), 0.0);
|
||||
let h3 = normalize(vec3<f32>(l.x, l.y, l.z + 1.0));
|
||||
let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y;
|
||||
let lc = P.f[4].xyz;
|
||||
let metal = P.f[1].w;
|
||||
let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc;
|
||||
let spec_col = (1.0 - metal) * lc + metal * c * lc;
|
||||
let o = c * lit + (sp * intensity) * spec_col;
|
||||
textureStore(out, p, vec4<f32>(o * alpha, alpha));
|
||||
}
|
||||
|
||||
// 0.5 + atan2(x, z) / 2π, kept on the side of the ±π seam that the sign of `x` puts it on (a
|
||||
// GPU's approximate atan2 can land just across it, which wraps to the opposite edge of the
|
||||
// texture). x = ±0 takes the CPU's −π (its zero is negative there).
|
||||
fn ftr_lon_u(x: f32, z: f32) -> f32 {
|
||||
let u = 0.5 + atan2(x, z) / 6.283185307179586;
|
||||
if (x <= 0.0) {
|
||||
return clamp(u, 0.0, 0.5);
|
||||
}
|
||||
return clamp(u, 0.5, 0.99999994);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Cylinder / CC Sphere (perspective.rs)
|
||||
// Shading (perspective::Shading): f[3] = (light direction, intensity); f[4] = (light colour,
|
||||
// ambient); f[5] = (diffuse, specular, shininess); f[6] = half vector. The layer rectangle in
|
||||
// f[1] = (x0, y0, width, height).
|
||||
|
||||
fn ftr_shade(px: vec4<f32>, n: vec3<f32>) -> vec4<f32> {
|
||||
let c = ftr_unpremul(px);
|
||||
let a = max(px.w, 0.0);
|
||||
if (a <= 0.0) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
let nl = max(dot(n, P.f[3].xyz), 0.0);
|
||||
var spec = 0.0;
|
||||
if (nl > 0.0) {
|
||||
spec = powz(max(dot(n, P.f[6].xyz), 0.0), P.f[5].z) * P.f[5].y;
|
||||
}
|
||||
let lc = P.f[4].xyz * P.f[3].w;
|
||||
let o = c * (P.f[4].w + P.f[5].x * nl * lc) + spec * lc;
|
||||
return vec4<f32>(o * a, a);
|
||||
}
|
||||
|
||||
// perspective::texel: the layer as a texture at (u, v), wrapping horizontally.
|
||||
fn ftr_texel(u0: f32, v: f32) -> vec4<f32> {
|
||||
let u = fract_euclid(u0);
|
||||
if (v < 0.0 || v > 1.0) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
let r = P.f[1];
|
||||
return sample_bilinear_clamped(src, r.x + u * r.z, r.y + v * r.w);
|
||||
}
|
||||
|
||||
fn ftr_over(top: vec4<f32>, bottom: vec4<f32>) -> vec4<f32> {
|
||||
return top + bottom * (1.0 - top.w);
|
||||
}
|
||||
|
||||
fn ftr_faces(mode: u32, front: vec4<f32>, back: vec4<f32>) -> vec4<f32> {
|
||||
if (mode == 1u) {
|
||||
return front;
|
||||
}
|
||||
if (mode == 2u) {
|
||||
return back;
|
||||
}
|
||||
return ftr_over(front, back);
|
||||
}
|
||||
|
||||
// Cylinder: u[0].x = render; f[0] = (cx, cy, radius, layer height); f[2].x = rotation.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let d = ftr_centre(p) - P.f[0].xy;
|
||||
let v = d.y / P.f[0].w + 0.5;
|
||||
let s = d.x / P.f[0].z;
|
||||
if (abs(s) >= 1.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let tau = 6.283185307179586;
|
||||
let th = asin(s);
|
||||
let cz = cos(th);
|
||||
let spin = P.f[2].x;
|
||||
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
|
||||
let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3<f32>(-s, 0.0, cz));
|
||||
textureStore(out, p, ftr_faces(P.u[0].x, front, back));
|
||||
}
|
||||
|
||||
// Sphere: u[0].x = render; f[0] = (cx, cy, radius); f[7..10] = the rotation matrix rows.
|
||||
fn ftr_sphere_tex(q: vec3<f32>) -> vec4<f32> {
|
||||
let m0 = P.f[7].xyz;
|
||||
let m1 = P.f[8].xyz;
|
||||
let m2 = P.f[9].xyz;
|
||||
let t = m0 * q.x + m1 * q.y + m2 * q.z;
|
||||
let lat = asin(clamp(t.y, -1.0, 1.0));
|
||||
return ftr_texel(ftr_lon_u(t.x, t.z), lat / 3.141592653589793 + 0.5);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_sphere(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let sxy = (ftr_centre(p) - P.f[0].xy) / P.f[0].z;
|
||||
let d2 = sxy.x * sxy.x + sxy.y * sxy.y;
|
||||
if (d2 >= 1.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let z = sqrt(1.0 - d2);
|
||||
let front = ftr_shade(ftr_sphere_tex(vec3<f32>(sxy, z)), vec3<f32>(sxy, z));
|
||||
let back = ftr_shade(ftr_sphere_tex(vec3<f32>(sxy, -z)), vec3<f32>(-sxy, z));
|
||||
textureStore(out, p, ftr_faces(P.u[0].x, front, back));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Spotlight (perspective::spotlight)
|
||||
// u[0].x = light only; f[0] = (from x, y, height, intensity); f[1] = (axis, inner angle);
|
||||
// f[2] = (colour, cone angle).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_spot(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let d = ftr_centre(p) - P.f[0].xy;
|
||||
let v = normalize(vec3<f32>(d, -P.f[0].z));
|
||||
let axis = P.f[1].xyz;
|
||||
// acos(v · axis), well conditioned near the axis.
|
||||
let ang = atan2(length(cross(v, axis)), dot(v, axis));
|
||||
let m = (1.0 - ftr_smoothstep(P.f[1].w, P.f[2].w, ang)) * P.f[0].w;
|
||||
let px = textureLoad(src, p, 0);
|
||||
if (P.u[0].x != 0u) {
|
||||
let a = clamp(m, 0.0, 1.0);
|
||||
textureStore(out, p, vec4<f32>(P.f[2].xyz * a, a));
|
||||
} else {
|
||||
textureStore(out, p, vec4<f32>(px.xyz * (m * P.f[2].xyz), px.w));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Environment (perspective2::cc_environment)
|
||||
// aux = the relief height map (x); data = the environment image (rows). u[0].x = mapping;
|
||||
// f[0] = (1 / buffer scale, height, environment scale); f[1] = (environment offset, size).
|
||||
|
||||
fn ftr_env_uv(mapping: u32, r: vec3<f32>) -> vec2<f32> {
|
||||
if (mapping == 1u) {
|
||||
let m = 2.0 * max(sqrt(r.x * r.x + r.y * r.y + (r.z + 1.0) * (r.z + 1.0)), 1e-9);
|
||||
return vec2<f32>(0.5 + r.x / m, 0.5 + r.y / m);
|
||||
}
|
||||
if (mapping == 2u) {
|
||||
let ax = abs(r.x);
|
||||
let ay = abs(r.y);
|
||||
let az = abs(r.z);
|
||||
var cr: vec4<f32>;
|
||||
if (ax >= ay && ax >= az) {
|
||||
if (r.x > 0.0) {
|
||||
cr = vec4<f32>(2.0, 1.0, -r.z / ax, r.y / ax);
|
||||
} else {
|
||||
cr = vec4<f32>(0.0, 1.0, r.z / ax, r.y / ax);
|
||||
}
|
||||
} else if (ay >= az) {
|
||||
if (r.y < 0.0) {
|
||||
cr = vec4<f32>(1.0, 0.0, r.x / ay, -r.z / ay);
|
||||
} else {
|
||||
cr = vec4<f32>(1.0, 2.0, r.x / ay, r.z / ay);
|
||||
}
|
||||
} else if (r.z > 0.0) {
|
||||
cr = vec4<f32>(1.0, 1.0, r.x / az, r.y / az);
|
||||
} else {
|
||||
cr = vec4<f32>(1.0, 3.0, r.x / az, -r.y / az);
|
||||
}
|
||||
return vec2<f32>((cr.x + (cr.z + 1.0) * 0.5) / 3.0, (cr.y + (cr.w + 1.0) * 0.5) / 4.0);
|
||||
}
|
||||
let lat = asin(clamp(r.y, -1.0, 1.0));
|
||||
return vec2<f32>(ftr_lon_u(r.x, r.z), 0.5 + lat / 3.141592653589793);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_env(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let a = textureLoad(src, p, 0).w;
|
||||
if (a <= 0.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let g = ftr_grad(p) * P.f[0].x;
|
||||
let height = P.f[0].y;
|
||||
let n = normalize(vec3<f32>(-g.x * height, -g.y * height, 1.0));
|
||||
let d = -n.z;
|
||||
let r = vec3<f32>(-2.0 * d * n.x, -2.0 * d * n.y, -(-1.0 - 2.0 * d * n.z));
|
||||
let uv = ftr_env_uv(P.u[0].x, r);
|
||||
let es = P.f[0].z;
|
||||
let ex = fract_euclid(uv.x) * P.f[1].z * es + P.f[1].x;
|
||||
let ey = clamp(uv.y, 0.0, 1.0) * P.f[1].w * es + P.f[1].y;
|
||||
let env = ftr_row_bilinear_clamped(ex, ey);
|
||||
let ec = env.xyz * (1.0 / max(env.w, 1e-3));
|
||||
textureStore(out, p, vec4<f32>(ec * a, a));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 3D Glasses (perspective2::glasses_3d)
|
||||
|
||||
// Translate by f[0].xy (perspective2::shift).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_shift(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let f = ftr_centre(p) - P.f[0].xy;
|
||||
textureStore(out, p, sample_bilinear(src, f.x, f.y));
|
||||
}
|
||||
|
||||
// src = left view, aux = right view. u[0].x = 3D view; f[0] = (width, height, balance).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_glasses(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!ftr_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let f = ftr_centre(p);
|
||||
let w = P.f[0].x;
|
||||
let h = P.f[0].y;
|
||||
let k = P.f[0].z;
|
||||
let view = P.u[0].x;
|
||||
var o: vec4<f32>;
|
||||
if (view == 0u) {
|
||||
if (f.x < w * 0.5) {
|
||||
o = sample_bilinear(src, f.x * 2.0, f.y);
|
||||
} else {
|
||||
o = sample_bilinear(aux, (f.x - w * 0.5) * 2.0, f.y);
|
||||
}
|
||||
} else if (view == 1u) {
|
||||
if (f.y < h * 0.5) {
|
||||
o = sample_bilinear(src, f.x, f.y * 2.0);
|
||||
} else {
|
||||
o = sample_bilinear(aux, f.x, (f.y - h * 0.5) * 2.0);
|
||||
}
|
||||
} else if (view == 2u) {
|
||||
if (p.y % 2 == 0) {
|
||||
o = textureLoad(src, p, 0);
|
||||
} else {
|
||||
o = textureLoad(aux, p, 0);
|
||||
}
|
||||
} else {
|
||||
let lp = textureLoad(src, p, 0);
|
||||
let rp = textureLoad(aux, p, 0);
|
||||
let lc = ftr_unpremul(lp);
|
||||
let rc = ftr_unpremul(rp);
|
||||
let a = max(max(lp.w, 0.0), max(rp.w, 0.0));
|
||||
let ll = luminance(lc);
|
||||
let rl = luminance(rc);
|
||||
var c: vec3<f32>;
|
||||
switch view {
|
||||
case 3u: {
|
||||
c = vec3<f32>(ll, rl, 0.0);
|
||||
}
|
||||
case 4u: {
|
||||
c = vec3<f32>(ll, 0.0, rl);
|
||||
}
|
||||
case 5u: {
|
||||
c = vec3<f32>(lc.x + (ll - lc.x) * k, rc.y, rc.z);
|
||||
}
|
||||
case 6u: {
|
||||
c = vec3<f32>(ll * (1.0 - 0.5 * k) + lc.x * 0.5 * k, rl, 0.0);
|
||||
}
|
||||
case 7u: {
|
||||
c = vec3<f32>(ll * (1.0 - 0.5 * k) + lc.x * 0.5 * k, 0.0, rl);
|
||||
}
|
||||
default: {
|
||||
c = abs(lc - rc);
|
||||
}
|
||||
}
|
||||
o = vec4<f32>(c * a, a);
|
||||
}
|
||||
textureStore(out, p, o);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Environment Light Background layers (`three_d::compose::draw_sky`): the layer's
|
||||
// equirectangular image looked up by each pixel's view ray through the camera, composited over
|
||||
// the canvas. src = the canvas, aux = the layer's buffer. f[0] = (forward, zoom); f[1] =
|
||||
// (right, orthographic); f[2] = (down, environment rotation); f[3] = (x offset, y offset,
|
||||
// 1 / scale, opacity) with the offsets ROI / scale − view size / 2; f[4] = (image width, height).
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn adv_sky(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
let dims = out_dims();
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
let fwd = P.f[0].xyz;
|
||||
var dir = fwd;
|
||||
if (P.f[1].w == 0.0) {
|
||||
let cx = (f32(p.x) + 0.5) * P.f[3].z + P.f[3].x;
|
||||
let cy = (f32(p.y) + 0.5) * P.f[3].z + P.f[3].y;
|
||||
dir = normalize(fwd * P.f[0].w + P.f[1].xyz * cx + P.f[2].xyz * cy);
|
||||
}
|
||||
// shade::equirect_uv.
|
||||
let pi = 3.141592653589793;
|
||||
var u = 0.5 + (atan2(dir.x, dir.z) + P.f[2].w) / (2.0 * pi);
|
||||
let v = acos(clamp(-dir.y, -1.0, 1.0)) / pi;
|
||||
u = u - floor(u);
|
||||
let iw = P.f[4].x;
|
||||
let ih = P.f[4].y;
|
||||
let s = sample_bilinear(aux, clamp(u * iw, 0.5, iw - 0.5), clamp(v * ih, 0.5, ih - 0.5));
|
||||
let op = P.f[3].w;
|
||||
let d = textureLoad(src, p, 0);
|
||||
textureStore(out, p, s * op + d * (1.0 - s.w * op));
|
||||
}
|
||||
|
|
@ -562,3 +562,45 @@ fn backend_selection_and_display_frames() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browsers' WGSL compilers reject an f32 literal whose exact decimal value lies outside the f32
|
||||
/// range (`3.40282347e38` rounds to f32::MAX in Rust but exceeds it), which invalidates the whole
|
||||
/// module (every kernel); naga accepts it, so native runs can't catch it. Every float literal
|
||||
/// with an exponent must be within the f32 range exactly.
|
||||
#[test]
|
||||
fn wgsl_float_literals_fit_f32() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders");
|
||||
let mut checked = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let src = std::fs::read_to_string(&path).unwrap();
|
||||
for (n, line) in src.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or("");
|
||||
let bytes = code.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let starts = bytes[i].is_ascii_digit() && (i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_' || bytes[i - 1] == b'.'));
|
||||
if !starts {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i;
|
||||
while j < bytes.len()
|
||||
&& (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'.' || ((bytes[j] == b'-' || bytes[j] == b'+') && matches!(bytes[j - 1], b'e' | b'E')))
|
||||
{
|
||||
j += 1;
|
||||
}
|
||||
let lit = code[i..j].trim_end_matches('f');
|
||||
if !lit.starts_with("0x")
|
||||
&& lit.contains(['e', 'E'])
|
||||
&& let Ok(v) = lit.parse::<f64>()
|
||||
{
|
||||
checked += 1;
|
||||
assert!(v.abs() <= f32::MAX as f64, "{}:{}: {lit} is outside the f32 range", path.display(), n + 1);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() {
|
|||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||||
eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0);
|
||||
// A blend mode in the run takes the CPU's 2D path (its scene still renders on the GPU).
|
||||
// A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU).
|
||||
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
|
||||
effectcraft_color::BlendMode::Screen;
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
|
|
@ -440,3 +440,90 @@ fn gpu_environment_light_on_flat_extrusions_has_no_nan() {
|
|||
assert_eq!(nan, 0, "non-finite pixels");
|
||||
compare(&s, &g);
|
||||
}
|
||||
|
||||
/// Advanced 3D runs whose layers take the 2D compositing path (blend modes, a 2D and a 3D track
|
||||
/// matte, Preserve Transparency) and environment backgrounds composite on the GPU: against the
|
||||
/// CPU compositor on the same (GPU) rasters they agree to float rounding; against the CPU
|
||||
/// rasteriser within the raster tolerance.
|
||||
#[test]
|
||||
fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
|
||||
let Some(g) = gpu() else { return };
|
||||
for case in 0..10 {
|
||||
let (mut p, cid) = scene_project();
|
||||
if case >= 4 {
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
}
|
||||
let classic = case >= 8;
|
||||
let case = if classic { 3 } else { case % 4 };
|
||||
if classic {
|
||||
// Classic 3D: the sky on the GPU too, then the run's planes.
|
||||
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
|
||||
}
|
||||
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
// A varying environment image (so the sky's lookup shows; a CPU effect, so the frame's
|
||||
// only readback is its own).
|
||||
let mut next = p.next_id;
|
||||
let spec = effectcraft_effects::find("ec.generate.lensflare").unwrap();
|
||||
let ramp = effectcraft_effects::instantiate(spec, &mut build::Ids(&mut next), spec.name, [32.0, 16.0]);
|
||||
p.next_id = next;
|
||||
if case == 3 {
|
||||
// A 2D track matte layer above the run.
|
||||
let sid = p.add_item("M", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 120, height: 90, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: sid }, |m| {
|
||||
m.switches.video = false;
|
||||
set(m, "transform/rotation", Value::Scalar(20.0));
|
||||
});
|
||||
}
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
let env = c.layers.iter_mut().find(|l| l.environment).unwrap();
|
||||
env.props.sub_mut("effects").unwrap().children.push(ramp.into());
|
||||
let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect();
|
||||
let top = c.layers[0].id;
|
||||
fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer {
|
||||
c.layers.iter_mut().find(|l| l.id == id).unwrap()
|
||||
}
|
||||
match case {
|
||||
0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen,
|
||||
1 => {
|
||||
// The card is a 3D track matte (luma) for the sphere; the torus multiplies.
|
||||
let card = ids[0];
|
||||
by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Multiply;
|
||||
by_id(c, ids[2]).track_matte = Some(effectcraft_project::TrackMatte { layer: card, kind: effectcraft_project::MatteKind::Luma });
|
||||
by_id(c, ids[0]).switches.video = false;
|
||||
}
|
||||
2 => {
|
||||
// Preserve Transparency and an Overlay layer.
|
||||
by_id(c, ids[0]).preserve_transparency = true;
|
||||
by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Overlay;
|
||||
}
|
||||
_ => {
|
||||
// An environment background behind the run, and the 2D track matte.
|
||||
c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true;
|
||||
by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha });
|
||||
}
|
||||
}
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
let r0 = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
if case < 3 {
|
||||
assert!(r0.split_adv_run(&ctx, &run, (240, 160)).is_some(), "case {case}: split");
|
||||
}
|
||||
let cpu = r0.comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
// The CPU compositor on the GPU's rasters.
|
||||
let hybrid = r.comp_frame_cpu(cid, Tick::ZERO);
|
||||
let before = g.context().transfer_stats().readbacks;
|
||||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||||
// On the device end to end: the frame's own readback only (no CPU fallback).
|
||||
assert_eq!(g.context().transfer_stats().readbacks - before, 1, "case {case}: readbacks");
|
||||
let (share, max) = off_share(&hybrid.data, &gimg.data);
|
||||
eprintln!("split case {case}: gpu walk vs cpu compositing {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.001, "case {case}: {:.3} % of pixels differ (max {max})", share * 100.0);
|
||||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||||
eprintln!("split case {case}: gpu vs cpu {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.015, "case {case}: {:.3} % of pixels differ from the CPU", share * 100.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
//! Deferred readbacks (the browser worker's WebGPU path) vs the CPU: a frame renders in passes
|
||||
//! until no readback is missing, and the result matches the CPU render. Natively the same
|
||||
//! device works with readbacks deferred; a device poll delivers them, as the browser's event
|
||||
//! loop does.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource};
|
||||
use effectcraft_render::{Accelerator, Backend, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::Gpu;
|
||||
use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
|
||||
|
||||
/// The device, held exclusively: deferred state is per device and a frame renders at a time
|
||||
/// (a frame worker renders one frame at a time too).
|
||||
fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let g = G.get_or_init(|| pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok()).as_ref()?;
|
||||
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
|
||||
}
|
||||
|
||||
/// Footage with GPU effects around a CPU-only one, a solid with Glow, and a precomp whose
|
||||
/// layer is blurred inside and outside (a chain whose input is another chain's result).
|
||||
fn scene() -> Scene {
|
||||
let cpu_only = "ec.blur.ccradial";
|
||||
assert!(!effectcraft_effects::GPU_EFFECTS.contains(&cpu_only));
|
||||
let mut s = Scene::new(BitDepth::Bpc32);
|
||||
let mut bg = s.footage(97, 61);
|
||||
s.effect(&mut bg, "ec.blur.gaussian", &[("blurriness", n(5.0))]);
|
||||
s.effect(&mut bg, cpu_only, &[]);
|
||||
s.effect(&mut bg, "ec.color.levels", &[("gamma", n(1.3))]);
|
||||
s.push(bg);
|
||||
let mut solid = s.solid([0.9, 0.4, 0.1], 30, 20);
|
||||
s.effect(&mut solid, "ec.stylize.glow", &[("threshold", n(20.0)), ("radius", n(6.0))]);
|
||||
set(&mut solid, "transform/position", v3(30.0, 20.0));
|
||||
s.push(solid);
|
||||
// Precomp: a footage layer blurred inside; the precomp layer blurred again outside.
|
||||
let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let inner_id = s.p.add_item("Inner", effectcraft_color::Label::Blue, None, ItemKind::Comp(inner.clone().into()));
|
||||
{
|
||||
let mut ins = Scene { p: std::mem::take(&mut s.p), cid: inner_id, comp: inner };
|
||||
let mut l = ins.footage(50, 30);
|
||||
ins.effect(&mut l, "ec.blur.fastbox", &[("radius", n(3.0))]);
|
||||
ins.push(l);
|
||||
s.p = ins.p;
|
||||
}
|
||||
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
|
||||
s.effect(&mut pre, "ec.blur.directional", &[("length", n(6.0)), ("direction", Value::Scalar(30.0))]);
|
||||
set(&mut pre, "transform/position", v3(60.0, 35.0));
|
||||
s.push(pre);
|
||||
s
|
||||
}
|
||||
|
||||
/// Render in passes with deferred readbacks; returns the frame and the number of passes.
|
||||
fn render_passes(g: &Gpu, s: &Scene, opts: RenderOpts, cache: &LayerCache, t: Tick) -> (effectcraft_raster::Image, usize) {
|
||||
cache.set_gate(g.miss_gate());
|
||||
let mut r = Renderer::new(&s.p, &Pattern, opts);
|
||||
r.accel = Some(g);
|
||||
r.cache = Some(cache);
|
||||
g.frame_begin();
|
||||
for pass in 1..=12 {
|
||||
g.pass_begin();
|
||||
let img = r.comp_frame(s.cid, t);
|
||||
if !g.pass_missed() {
|
||||
assert_eq!(g.deferred().unwrap().in_flight(), 0);
|
||||
return (img, pass);
|
||||
}
|
||||
// The browser awaits `Gpu::settled`; natively a blocking poll delivers the readbacks.
|
||||
g.wait();
|
||||
pollster::block_on(g.settled());
|
||||
}
|
||||
panic!("the frame never completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_passes_match_the_cpu() {
|
||||
let Some((g, _lock)) = deferred_gpu() else { return };
|
||||
assert!(g.context().can_readback() && !g.context().can_wait());
|
||||
let s = scene();
|
||||
let t = Tick::from_seconds_f64(0.5);
|
||||
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..Default::default() }).comp_frame_cpu(s.cid, t);
|
||||
let tol = tolerance(BitDepth::Bpc32) * 2.0;
|
||||
// The whole frame on the GPU (GPU compositor + chains + the frame's readback).
|
||||
let cache = LayerCache::default();
|
||||
let (img, passes) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, ..Default::default() }, &cache, t);
|
||||
let d = diff(&cpu, &img, tol);
|
||||
assert!(d.over == 0, "whole-frame GPU: {d:?}");
|
||||
assert!(passes >= 3, "chains, the nested chain, then the frame readback: {passes} passes");
|
||||
// Again (a new frame: results are per frame; the layer cache holds the genuine buffers of
|
||||
// the cacheable layers): the same pixels.
|
||||
let (img2, passes2) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, ..Default::default() }, &cache, t);
|
||||
assert_eq!(img2.data, img.data);
|
||||
assert!(passes2 <= passes, "{passes2} <= {passes}");
|
||||
// CPU compositing with GPU chains (a region of interest keeps the frame off the GPU
|
||||
// compositor): every placeholder is gone by the last pass.
|
||||
let roi = Some([0.0, 0.0, 97.0, 61.0]);
|
||||
let cpu_roi = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi, ..Default::default() }).comp_frame_cpu(s.cid, t);
|
||||
let (img3, passes3) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, roi, ..Default::default() }, &LayerCache::default(), t);
|
||||
let d = diff(&cpu_roi, &img3, tol);
|
||||
assert!(d.over == 0, "CPU composite, GPU chains: {d:?}");
|
||||
assert!(passes3 >= 2);
|
||||
let (p, rb, inflight) = g.deferred_stats().unwrap();
|
||||
assert!(p >= (passes + passes2 + passes3) as u64 && rb > 0 && inflight == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_never_reach_the_layer_cache() {
|
||||
let Some((g, _lock)) = deferred_gpu() else { return };
|
||||
let s = scene();
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let cache = LayerCache::default();
|
||||
cache.set_gate(g.miss_gate());
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() });
|
||||
r.accel = Some(g);
|
||||
r.cache = Some(&cache);
|
||||
g.frame_begin();
|
||||
g.pass_begin();
|
||||
let _ = r.comp_frame(s.cid, t);
|
||||
assert!(g.pass_missed());
|
||||
// Only buffers computed before the first miss were kept (the first layer's input up to its
|
||||
// first chain: nothing with an effect result).
|
||||
let first_pass_entries = cache.stats().entries;
|
||||
g.wait();
|
||||
pollster::block_on(g.settled());
|
||||
let (img, _) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() }, &cache, t);
|
||||
assert!(cache.stats().entries > first_pass_entries, "{first_pass_entries} → {:?}", cache.stats());
|
||||
// A fresh CPU render through the same cache (served from it) agrees with the CPU.
|
||||
let mut cr = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() });
|
||||
cr.cache = Some(&cache);
|
||||
let cached = cr.comp_frame_cpu(s.cid, t);
|
||||
let fresh =
|
||||
Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() }).comp_frame_cpu(s.cid, t);
|
||||
let tol = tolerance(BitDepth::Bpc32) * 2.0;
|
||||
assert_eq!(diff(&fresh, &cached, tol).over, 0);
|
||||
assert_eq!(diff(&fresh, &img, tol).over, 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
//! GPU CC light effects (CC Light Rays, CC Light Burst 2.5, CC Light Sweep, CC Light Wipe) vs
|
||||
//! the CPU effects (the oracle): direct on a buffer at full and half resolution, as
|
||||
//! adjustment, and composited at 8 and 32 bpc.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
use crate::tests::{c, effect_case, n};
|
||||
|
||||
fn e(v: u32) -> Value {
|
||||
Value::Enum(v)
|
||||
}
|
||||
|
||||
fn on() -> Value {
|
||||
Value::Bool(true)
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Value {
|
||||
Value::Vec2([x, y])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_rays() {
|
||||
effect_case("ec.generate.cclightrays", &[("center", pt(30.0, 20.0))]);
|
||||
effect_case(
|
||||
"ec.generate.cclightrays",
|
||||
&[("center", pt(40.0, 18.0)), ("intensity", n(250.0)), ("radius", n(15.0)), ("shape", e(1)), ("transferMode", e(1))],
|
||||
);
|
||||
effect_case(
|
||||
"ec.generate.cclightrays",
|
||||
&[("intensity", n(80.0)), ("colorFromSource", Value::Bool(false)), ("color", c(1.0, 0.6, 0.2)), ("transferMode", e(2)), ("warpSoftness", n(120.0))],
|
||||
);
|
||||
effect_case("ec.generate.cclightrays", &[("intensity", n(150.0)), ("transferMode", e(3)), ("radius", n(25.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_burst() {
|
||||
effect_case("ec.generate.cclightburst", &[("center", pt(30.0, 20.0)), ("burst", e(1))]);
|
||||
effect_case("ec.generate.cclightburst", &[("rayLength", n(80.0)), ("burst", e(2)), ("intensity", n(150.0))]);
|
||||
effect_case("ec.generate.cclightburst", &[("rayLength", n(30.0)), ("setColor", on()), ("color", c(0.3, 0.7, 1.0)), ("burst", e(1))]);
|
||||
effect_case("ec.generate.cclightburst", &[("rayLength", n(40.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_sweep() {
|
||||
effect_case("ec.generate.cclightsweep", &[("center", pt(30.0, 20.0))]);
|
||||
effect_case("ec.generate.cclightsweep", &[("shape", e(0)), ("width", n(30.0)), ("edgeThickness", n(4.0)), ("lightReceptionMode", e(1))]);
|
||||
effect_case("ec.generate.cclightsweep", &[("shape", e(2)), ("direction", n(60.0)), ("lightReceptionMode", e(2)), ("lightColor", c(1.0, 0.8, 0.4))]);
|
||||
effect_case("ec.generate.cclightsweep", &[("edgeThickness", n(0.0)), ("sweepIntensity", n(120.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_wipe() {
|
||||
effect_case("ec.transition.cclightwipe", &[("completion", n(40.0)), ("center", pt(30.0, 20.0))]);
|
||||
effect_case("ec.transition.cclightwipe", &[("completion", n(25.0)), ("shape", e(0)), ("direction", n(30.0)), ("colorFromSource", on())]);
|
||||
effect_case("ec.transition.cclightwipe", &[("completion", n(60.0)), ("shape", e(2)), ("reverse", on()), ("color", c(0.2, 0.9, 0.5))]);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
//! GPU Numbers and Timecode vs the CPU effects (the oracle): direct on a buffer at full and half
|
||||
//! resolution, as adjustment, and composited at 8 and 32 bpc.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
use crate::tests::{c, effect_case, n};
|
||||
|
||||
fn e(v: u32) -> Value {
|
||||
Value::Enum(v)
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Value {
|
||||
Value::Vec2([x, y])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers() {
|
||||
effect_case("ec.text.numbers", &[("format/value", n(42.5)), ("size", n(14.0))]);
|
||||
effect_case(
|
||||
"ec.text.numbers",
|
||||
&[("format/value", n(-7.0)), ("size", n(12.0)), ("fillAndStroke/displayOptions", e(2)), ("fillAndStroke/strokeWidth", n(1.5)), ("tracking", n(2.0))],
|
||||
);
|
||||
effect_case(
|
||||
"ec.text.numbers",
|
||||
&[
|
||||
("format/value", n(3.25)),
|
||||
("size", n(16.0)),
|
||||
("fillAndStroke/displayOptions", e(3)),
|
||||
("fillAndStroke/fillColor", c(0.2, 0.6, 1.0)),
|
||||
("compositeOnOriginal", Value::Bool(false)),
|
||||
],
|
||||
);
|
||||
effect_case("ec.text.numbers", &[("size", n(10.0)), ("fillAndStroke/displayOptions", e(1)), ("fillAndStroke/position", pt(20.0, 15.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode() {
|
||||
effect_case("ec.text.timecode", &[("textSize", n(10.0)), ("textPosition", pt(35.0, 22.0))]);
|
||||
effect_case(
|
||||
"ec.text.timecode",
|
||||
&[("textSize", n(9.0)), ("displayFormat", e(1)), ("renderOnOriginal", Value::Bool(false)), ("textColor", c(1.0, 0.8, 0.1))],
|
||||
);
|
||||
effect_case("ec.text.timecode", &[("textSize", n(8.0)), ("showBox", Value::Bool(true)), ("boxColor", c(0.1, 0.1, 0.4)), ("opacity", n(70.0))]);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
//! GPU time effects (Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time,
|
||||
//! Pixel Motion Blur, Timewarp) vs the CPU effects (the oracle). They fetch other frames through
|
||||
//! the effect host: a precomp whose content moves, at a nonzero time, composited at 8 and
|
||||
//! 32 bpc (the direct cases have no host and pass the layer through).
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set, v3};
|
||||
|
||||
fn e(v: u32) -> Value {
|
||||
Value::Enum(v)
|
||||
}
|
||||
|
||||
fn on() -> Value {
|
||||
Value::Bool(true)
|
||||
}
|
||||
|
||||
/// `id` with `vals` on a moving precomp; `map` adds a hidden footage layer and sets parameter
|
||||
/// `map` to it.
|
||||
fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let mut s = Scene::new(depth);
|
||||
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
|
||||
s.push(bg);
|
||||
let mut vals = vals.to_vec();
|
||||
if let Some(param) = map {
|
||||
let mut m = s.footage(50, 36);
|
||||
m.switches.video = false;
|
||||
let mid = s.push(m);
|
||||
vals.push((param, Value::Layer(Some(mid.0))));
|
||||
}
|
||||
let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let mut a = s.footage(30, 24);
|
||||
let pos = a.props.prop_mut("transform/position").unwrap();
|
||||
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))];
|
||||
let rot = a.props.prop_mut("transform/rotation").unwrap();
|
||||
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
|
||||
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
|
||||
s.effect(&mut l, id, &vals);
|
||||
set(&mut l, "transform/rotation", Value::Scalar(8.0));
|
||||
s.push(l);
|
||||
let t = Tick::from_seconds_f64(0.4);
|
||||
let label = format!("{id} {vals:?} {depth:?}");
|
||||
check(&label, compare_at(&s, opts(), t), 0.0);
|
||||
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_host() {
|
||||
for id in
|
||||
["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"]
|
||||
{
|
||||
effect_case(id, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_averages() {
|
||||
moving("ec.time.ccforcemotionblur", &[], None);
|
||||
moving("ec.time.ccforcemotionblur", &[("motionBlurLevels", n(5.0)), ("shutterAngle", n(400.0))], None);
|
||||
moving("ec.time.ccwidetime", &[], None);
|
||||
moving("ec.time.ccwidetime", &[("forwardSteps", n(1.0)), ("backwardSteps", n(4.0))], None);
|
||||
moving("ec.time.pixelmotionblur", &[], None);
|
||||
moving("ec.time.pixelmotionblur", &[("shutterControl", e(1)), ("shutterAngle", n(300.0)), ("shutterSamples", n(5.0))], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_difference() {
|
||||
moving("ec.time.timedifference", &[("timeOffset", n(-0.1))], None);
|
||||
for alpha in 0..9 {
|
||||
moving(
|
||||
"ec.time.timedifference",
|
||||
&[("timeOffset", n(0.2)), ("contrast", n(40.0)), ("absoluteDifference", Value::Bool(alpha % 2 == 0)), ("alphaChannel", e(alpha))],
|
||||
None,
|
||||
);
|
||||
}
|
||||
moving("ec.time.timedifference", &[("timeOffset", n(0.1)), ("alphaChannel", e(2))], Some("targetLayer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_displacement() {
|
||||
moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.3)), ("timeResolution", n(10.0))], None);
|
||||
moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.5)), ("timeResolution", n(30.0))], Some("displacementMapLayer"));
|
||||
moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(2.0)), ("stretchMap", Value::Bool(false))], Some("displacementMapLayer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timewarp() {
|
||||
for method in 0..3 {
|
||||
moving("ec.time.timewarp", &[("method", e(method)), ("speed", n(37.0))], None);
|
||||
}
|
||||
moving("ec.time.timewarp", &[("method", e(1)), ("adjustTimeBy", e(1)), ("sourceFrame", n(7.4))], None);
|
||||
moving("ec.time.timewarp", &[("method", e(2)), ("speed", n(43.0)), ("motionBlur/enableMotionBlur", on()), ("motionBlur/shutterSamples", n(3.0))], None);
|
||||
moving(
|
||||
"ec.time.timewarp",
|
||||
&[
|
||||
("speed", n(61.0)),
|
||||
("tuning/filtering", e(1)),
|
||||
("tuning/errorThreshold", n(30.0)),
|
||||
("sourceCrops/leftCrop", n(4.0)),
|
||||
("sourceCrops/topCrop", n(3.0)),
|
||||
],
|
||||
None,
|
||||
);
|
||||
moving("ec.time.timewarp", &[("speed", n(55.0)), ("tuning/buildFromOneImage", on())], None);
|
||||
moving("ec.time.timewarp", &[("speed", n(47.0))], Some("warpLayer"));
|
||||
// A Matte Layer renders on the CPU (catalog::gpu_supported).
|
||||
moving("ec.time.timewarp", &[("speed", n(47.0)), ("show", e(0))], Some("matteLayer"));
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
//! GPU transition and perspective effects (Block Dissolve, the CC transitions, Radial Shadow,
|
||||
//! CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC Spotlight, CC Environment, 3D Glasses)
|
||||
//! vs the CPU effects (the oracle): direct on a buffer at full and half resolution, as
|
||||
//! adjustment, and composited at 8 and 32 bpc; effects reading other layers also with a layer
|
||||
//! chosen.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::BitDepth;
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
|
||||
|
||||
fn e(v: u32) -> Value {
|
||||
Value::Enum(v)
|
||||
}
|
||||
|
||||
fn on() -> Value {
|
||||
Value::Bool(true)
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Value {
|
||||
Value::Vec2([x, y])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_dissolve() {
|
||||
effect_case("ec.transition.blockdissolve", &[("completion", n(40.0))]);
|
||||
effect_case("ec.transition.blockdissolve", &[("completion", n(55.0)), ("blockWidth", n(7.0)), ("blockHeight", n(5.5)), ("feather", n(3.0))]);
|
||||
effect_case("ec.transition.blockdissolve", &[("completion", n(30.0)), ("blockWidth", n(4.3)), ("blockHeight", n(9.0)), ("softEdges", Value::Bool(false))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_and_scale_wipes() {
|
||||
effect_case("ec.transition.ccgridwipe", &[("completion", n(40.0)), ("center", pt(33.0, 21.0))]);
|
||||
effect_case("ec.transition.ccgridwipe", &[("completion", n(60.0)), ("rotation", n(20.0)), ("border", n(3.0)), ("tiles", n(7.0)), ("shape", e(0))]);
|
||||
effect_case("ec.transition.ccgridwipe", &[("completion", n(35.0)), ("shape", e(2)), ("reverse", on()), ("tiles", n(5.0))]);
|
||||
effect_case("ec.transition.ccradialscalewipe", &[("completion", n(40.0)), ("center", pt(30.0, 20.0))]);
|
||||
effect_case("ec.transition.ccradialscalewipe", &[("completion", n(30.0)), ("center", pt(40.0, 25.0)), ("reverse", on())]);
|
||||
effect_case("ec.transition.ccscalewipe", &[("stretch", n(2.0)), ("center", pt(30.0, 20.0))]);
|
||||
effect_case("ec.transition.ccscalewipe", &[("stretch", n(0.7)), ("direction", n(200.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_driven_wipes() {
|
||||
effect_case("ec.transition.ccglasswipe", &[("completion", n(45.0))]);
|
||||
effect_case("ec.transition.ccglasswipe", &[("completion", n(30.0)), ("softness", n(40.0)), ("displacementAmount", n(25.0))]);
|
||||
effect_case("ec.transition.ccimagewipe", &[("completion", n(50.0))]);
|
||||
effect_case(
|
||||
"ec.transition.ccimagewipe",
|
||||
&[("completion", n(40.0)), ("property", e(5)), ("blur", n(4.0)), ("inverseGradient", on()), ("borderSoftness", n(20.0))],
|
||||
);
|
||||
effect_case("ec.transition.ccimagewipe", &[("completion", n(60.0)), ("property", e(6)), ("autoSoftness", Value::Bool(false))]);
|
||||
effect_case("ec.transition.ccwarpomatic", &[("completion", n(40.0))]);
|
||||
effect_case("ec.transition.ccwarpomatic", &[("completion", n(55.0)), ("reactor", e(1)), ("warpDirection", e(1))]);
|
||||
effect_case("ec.transition.ccwarpomatic", &[("completion", n(35.0)), ("reactor", e(2)), ("warpDirection", e(2)), ("blendSpan", n(30.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometric_transitions() {
|
||||
effect_case("ec.transition.ccjaws", &[("completion", n(30.0)), ("center", pt(33.0, 21.0))]);
|
||||
for shape in 1..4 {
|
||||
effect_case("ec.transition.ccjaws", &[("completion", n(20.0)), ("shape", e(shape)), ("direction", n(25.0)), ("width", n(13.0))]);
|
||||
}
|
||||
effect_case("ec.transition.cclinesweep", &[("completion", n(40.0))]);
|
||||
effect_case(
|
||||
"ec.transition.cclinesweep",
|
||||
&[("completion", n(50.0)), ("direction", n(35.0)), ("slant", n(-12.0)), ("thickness", n(7.0)), ("flipDirection", on())],
|
||||
);
|
||||
effect_case("ec.transition.cctwister", &[("completion", n(30.0)), ("center", pt(33.0, 21.0))]);
|
||||
effect_case("ec.transition.cctwister", &[("completion", n(60.0)), ("axis", n(10.0)), ("shading", Value::Bool(false))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective() {
|
||||
effect_case("ec.perspective.radialshadow", &[("lightSource", pt(20.0, 10.0)), ("projectionDistance", n(15.0))]);
|
||||
effect_case(
|
||||
"ec.perspective.radialshadow",
|
||||
&[
|
||||
("lightSource", pt(50.0, 15.0)),
|
||||
("softness", n(6.0)),
|
||||
("render", e(1)),
|
||||
("colorInfluence", n(60.0)),
|
||||
("resizeLayer", on()),
|
||||
("color", c(0.3, 0.0, 0.5)),
|
||||
],
|
||||
);
|
||||
effect_case("ec.perspective.radialshadow", &[("shadowOnly", on()), ("opacity", n(80.0)), ("resizeLayer", on())]);
|
||||
effect_case("ec.perspective.cccylinder", &[("position", pt(35.0, 22.0)), ("rotation", n(30.0))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("radius", n(60.0)), ("render", e(1)), ("lightDirection", n(70.0)), ("roughness", n(0.2))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("render", e(2)), ("lightHeight", n(20.0)), ("specular", n(60.0))]);
|
||||
effect_case("ec.perspective.ccsphere", &[("offset", pt(35.0, 22.0)), ("radius", n(18.0)), ("rotationX", n(20.0)), ("rotationY", n(35.0))]);
|
||||
effect_case("ec.perspective.ccsphere", &[("radius", n(20.0)), ("rotationZ", n(50.0)), ("render", e(2))]);
|
||||
effect_case("ec.perspective.ccspotlight", &[("from", pt(15.0, 8.0)), ("to", pt(35.0, 22.0))]);
|
||||
effect_case(
|
||||
"ec.perspective.ccspotlight",
|
||||
&[("from", pt(50.0, 5.0)), ("to", pt(30.0, 30.0)), ("coneAngle", n(35.0)), ("edgeSoftness", n(60.0)), ("render", e(1))],
|
||||
);
|
||||
effect_case("ec.perspective.ccspotlight", &[("to", pt(35.0, 22.0)), ("edgeSoftness", n(0.0)), ("color", c(1.0, 0.7, 0.3)), ("intensity", n(150.0))]);
|
||||
for mapping in 0..3 {
|
||||
effect_case("ec.perspective.ccenvironment", &[("mapping", e(mapping)), ("height", n(40.0))]);
|
||||
}
|
||||
effect_case("ec.perspective.ccenvironment", &[("filterEnvironment", Value::Bool(false))]);
|
||||
for view in 0..9 {
|
||||
effect_case("ec.perspective.3dglasses", &[("view3d", e(view)), ("sceneConvergence", n(4.0)), ("verticalAlignment", n(1.5))]);
|
||||
}
|
||||
effect_case("ec.perspective.3dglasses", &[("units", e(1)), ("sceneConvergence", n(3.0)), ("leftRightSwap", on()), ("balance", n(12.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort() {
|
||||
effect_case("ec.distort.ccbender", &[("amount", n(40.0)), ("top", pt(35.0, 0.0)), ("base", pt(35.0, 44.0))]);
|
||||
for style in 1..4 {
|
||||
effect_case(
|
||||
"ec.distort.ccbender",
|
||||
&[("amount", n(-30.0)), ("style", e(style)), ("top", pt(20.0, 5.0)), ("base", pt(40.0, 40.0)), ("adjustToDistance", on())],
|
||||
);
|
||||
}
|
||||
effect_case("ec.distort.ccblobbylize", &[]);
|
||||
effect_case(
|
||||
"ec.distort.ccblobbylize",
|
||||
&[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))],
|
||||
);
|
||||
effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]);
|
||||
}
|
||||
|
||||
/// Effects reading another layer (gradient, reveal, backside, environment, stereo views).
|
||||
#[test]
|
||||
fn with_other_layers() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![
|
||||
("ec.transition.ccglasswipe", vec![("completion", n(40.0)), ("gradientLayer", Value::Layer(None)), ("layerToReveal", Value::Layer(None))]),
|
||||
("ec.transition.ccimagewipe", vec![("completion", n(45.0)), ("layer", Value::Layer(None))]),
|
||||
("ec.transition.ccwarpomatic", vec![("completion", n(40.0)), ("reactorLayer", Value::Layer(None)), ("layerToReveal", Value::Layer(None))]),
|
||||
("ec.transition.cctwister", vec![("completion", n(70.0)), ("backside", Value::Layer(None))]),
|
||||
("ec.perspective.ccenvironment", vec![("environment", Value::Layer(None))]),
|
||||
("ec.perspective.3dglasses", vec![("view3d", e(5)), ("leftView", Value::Layer(None)), ("sceneConvergence", n(3.0))]),
|
||||
("ec.distort.ccblobbylize", vec![("blobLayer", Value::Layer(None))]),
|
||||
];
|
||||
for (id, vals) in cases {
|
||||
let mut s = Scene::new(depth);
|
||||
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
|
||||
s.push(bg);
|
||||
let mut other = s.footage(50, 36);
|
||||
other.switches.video = false;
|
||||
let oid = s.push(other);
|
||||
let vals: Vec<(&str, Value)> = vals.into_iter().map(|(k, v)| (k, if v == Value::Layer(None) { Value::Layer(Some(oid.0)) } else { v })).collect();
|
||||
let mut l = s.footage(70, 44);
|
||||
s.effect(&mut l, id, &vals);
|
||||
set(&mut l, "transform/rotation", Value::Scalar(8.0));
|
||||
s.push(l);
|
||||
let label = format!("{id} with a layer {depth:?}");
|
||||
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
|
||||
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
//! The per-frame compositing walk on the GPU: the same steps as `Renderer::draw_comp` (layer
|
||||
//! order, 3D runs, collapsed precomps, track mattes, Preserve Transparency, layer styles, motion
|
||||
//! blur, bit-depth quantisation, colour conversions), with layer pixels taken from the CPU
|
||||
//! renderer and its layer cache. Classic 3D runs and adjustment layers run on the GPU; steps
|
||||
//! without a GPU implementation (Advanced 3D runs, wireframes, anything a kernel declines) read
|
||||
//! the canvas back, run on the CPU and upload the result.
|
||||
//! renderer and its layer cache. Classic and Advanced 3D runs (environment skies included) and
|
||||
//! adjustment layers run on the GPU; steps without a GPU implementation (anything a kernel
|
||||
//! declines) read the canvas back, run on the CPU and upload the result.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -99,6 +99,17 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
|
|||
/// layers splitting the run as on the CPU; Advanced 3D, wireframes and runs the kernel cannot
|
||||
/// hold draw on the CPU.
|
||||
fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], canvas: &mut GpuImage) -> Option<()> {
|
||||
// Environment Light Background layers: the sky behind the rest of the run (drawn first, as
|
||||
// `three_d::compose::draw_run` does).
|
||||
if run.iter().any(|l| l.environment_background) {
|
||||
for l in run.iter().filter(|l| l.environment_background) {
|
||||
if let Some(sky) = r.sky_draw(ctx, l) {
|
||||
*canvas = crate::adv3d::sky(e, &sky, canvas)?;
|
||||
}
|
||||
}
|
||||
let rest: Vec<&'a Layer> = run.iter().copied().filter(|l| !l.environment_background).collect();
|
||||
return draw_3d(e, r, ctx, &rest, canvas);
|
||||
}
|
||||
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
|
||||
draw_3d(e, r, ctx, &run[..k], canvas)?;
|
||||
draw_layer(e, r, ctx, run[k], canvas)?;
|
||||
|
|
@ -115,6 +126,15 @@ fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Laye
|
|||
}
|
||||
return Some(());
|
||||
}
|
||||
// Advanced 3D with layers on the 2D compositing path (blend modes, track mattes, Preserve
|
||||
// Transparency).
|
||||
if let Some(split) = r.split_adv_run(ctx, run, (canvas.width, canvas.height)) {
|
||||
match draw_split(e, r, ctx, &split, canvas) {
|
||||
Some(img) => *canvas = img,
|
||||
None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?,
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
match r.prepare_3d_run(ctx, run, (canvas.width, canvas.height)).and_then(|prep| crate::classic3d::draw_run(e, &prep, canvas)) {
|
||||
Some(img) => *canvas = img,
|
||||
None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?,
|
||||
|
|
@ -122,6 +142,44 @@ fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Laye
|
|||
Some(())
|
||||
}
|
||||
|
||||
/// `three_d::adv::draw_run`'s split path on the GPU canvas: the plain layers as one scene over
|
||||
/// the canvas, then each special layer (far to near) rendered alone, hidden where the main
|
||||
/// scene is nearer, matted (a 3D matte through the camera, else the 2D placement), Preserve
|
||||
/// Transparency and blended. `None` = render the run on the CPU.
|
||||
fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_render::three_d::adv::SplitRun, canvas: &GpuImage) -> Option<GpuImage> {
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
let main_prep = split.main();
|
||||
if main_prep.out != (w, h) {
|
||||
return None;
|
||||
}
|
||||
let encode = !main_prep.linear;
|
||||
let main = crate::adv3d::resolve_run(e, &main_prep)?;
|
||||
let mut cv = match &main {
|
||||
Some(m) => crate::adv3d::finish(e, m, encode, Some(canvas)),
|
||||
None => canvas.clone(),
|
||||
};
|
||||
for i in 0..split.len() {
|
||||
let Some(iso) = crate::adv3d::resolve_run(e, &split.special(i))? else { continue };
|
||||
let iso = match &main {
|
||||
Some(m) => crate::adv3d::occluded(e, &iso, m, encode),
|
||||
None => crate::adv3d::finish(e, &iso, encode, None),
|
||||
};
|
||||
let matte3d = split.with_matte3d(i, |p| match p {
|
||||
None => Some(e.zeros(w, h)),
|
||||
Some(p) => Some(match crate::adv3d::resolve_run(e, p)? {
|
||||
Some(m) => crate::adv3d::finish(e, &m, encode, None),
|
||||
None => e.zeros(w, h),
|
||||
}),
|
||||
});
|
||||
let matte3d = match matte3d {
|
||||
Some(m) => Some(m?),
|
||||
None => None,
|
||||
};
|
||||
composite_iso_with(e, r, ctx, split.layer(i), iso, &mut cv, 1.0, matte3d, None)?;
|
||||
}
|
||||
Some(cv)
|
||||
}
|
||||
|
||||
fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> {
|
||||
let opacity = r.layer_opacity(ctx, layer);
|
||||
if opacity <= 0.0 {
|
||||
|
|
@ -225,21 +283,44 @@ fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf:
|
|||
}
|
||||
|
||||
/// Track matte / Preserve Transparency on an isolated layer render, then blend.
|
||||
fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, mut iso: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
|
||||
fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, iso: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
|
||||
composite_iso_with(e, r, ctx, layer, iso, canvas, opacity, None, r.pipe().levels)
|
||||
}
|
||||
|
||||
/// `Renderer::composite_iso_with`: the matte from `matte_img` when given (a 3D matte drawn
|
||||
/// through the camera), else the matte layer placed in 2D; the blend quantised to `levels`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn composite_iso_with(
|
||||
e: &mut Enc,
|
||||
r: &Renderer,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
mut iso: GpuImage,
|
||||
canvas: &mut GpuImage,
|
||||
opacity: f32,
|
||||
matte_img: Option<GpuImage>,
|
||||
levels: Option<f32>,
|
||||
) -> Option<()> {
|
||||
if let Some((m, kind)) = r.track_matte(ctx, layer) {
|
||||
let mut mimg = e.zeros(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
&& let Some(mb) = r.layer_buf(ctx, m)
|
||||
{
|
||||
let mo = ctx.opacity(m) as f32;
|
||||
mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?;
|
||||
}
|
||||
let mimg = match matte_img.filter(|i| (i.width, i.height) == (canvas.width, canvas.height)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let mut mimg = e.zeros(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
&& let Some(mb) = r.layer_buf(ctx, m)
|
||||
{
|
||||
let mo = ctx.opacity(m) as f32;
|
||||
mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?;
|
||||
}
|
||||
mimg
|
||||
}
|
||||
};
|
||||
iso = ops::matte(e, &iso, &mimg, kind);
|
||||
}
|
||||
if layer.preserve_transparency {
|
||||
iso = ops::preserve(e, &iso, canvas);
|
||||
}
|
||||
*canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, r.pipe().levels);
|
||||
*canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, levels);
|
||||
Some(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ use crate::eval::EvalCtx;
|
|||
pub struct LayerCache {
|
||||
inner: Mutex<Inner>,
|
||||
disk: Mutex<Option<(Arc<crate::disk_cache::DiskCache>, u64)>>,
|
||||
/// While set, inserts are dropped (see [`LayerCache::set_gate`]).
|
||||
gate: Mutex<Option<Arc<std::sync::atomic::AtomicBool>>>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
|
|
@ -72,9 +74,24 @@ impl LayerCache {
|
|||
LayerCache {
|
||||
inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }),
|
||||
disk: Mutex::new(None),
|
||||
gate: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop inserts while `gate` is set. A render whose GPU readbacks are still in flight (the
|
||||
/// browser's deferred readbacks, see [`crate::Accelerator::pass_missed`]) computes some
|
||||
/// buffers from placeholders; the accelerator raises the gate from its first missing
|
||||
/// readback to the end of the pass, so none of them is kept (in memory or on disk).
|
||||
pub fn set_gate(&self, gate: Option<Arc<std::sync::atomic::AtomicBool>>) {
|
||||
if let Ok(mut g) = self.gate.lock() {
|
||||
*g = gate;
|
||||
}
|
||||
}
|
||||
|
||||
fn gated(&self) -> bool {
|
||||
self.gate.lock().ok().and_then(|g| g.as_ref().map(|g| g.load(std::sync::atomic::Ordering::Relaxed))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see
|
||||
/// `footage_salt`); keys on disk are `(salt, key)`.
|
||||
pub fn set_disk(&self, disk: Option<(Arc<crate::disk_cache::DiskCache>, u64)>) {
|
||||
|
|
@ -130,6 +147,9 @@ impl LayerCache {
|
|||
}
|
||||
|
||||
pub fn insert(&self, key: u64, buf: Arc<Buf>) {
|
||||
if self.gated() {
|
||||
return;
|
||||
}
|
||||
let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key));
|
||||
if let Some((dc, dk)) = self.disk_key(key) {
|
||||
let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ pub enum Kind {
|
|||
}
|
||||
|
||||
impl Kind {
|
||||
fn dir(self) -> &'static str {
|
||||
/// The kind's folder name.
|
||||
pub fn dir(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Frame => "frames",
|
||||
Kind::Layer => "layers",
|
||||
|
|
@ -80,13 +81,98 @@ struct Meta {
|
|||
last_use: u64,
|
||||
}
|
||||
|
||||
/// The cache's index: entry sizes and least-recently-used order under a size limit. Portable
|
||||
/// (no file system): [`DiskCache`] keeps one over its folder, and the browser keeps one over the
|
||||
/// Origin Private File System (`apps/effectcraft-web`, where the files are written by workers).
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
pub struct DiskIndex {
|
||||
map: HashMap<(Kind, u128), Meta>,
|
||||
total: u64,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
impl DiskIndex {
|
||||
/// Record an entry (`bytes` on disk) as the most recently used; replaces an older record.
|
||||
pub fn insert(&mut self, kind: Kind, key: u128, bytes: u64) {
|
||||
self.clock += 1;
|
||||
let c = self.clock;
|
||||
if let Some(old) = self.map.insert((kind, key), Meta { bytes, last_use: c }) {
|
||||
self.total -= old.bytes;
|
||||
}
|
||||
self.total += bytes;
|
||||
}
|
||||
|
||||
/// Mark an entry as just used (a read). `false` when it is not indexed.
|
||||
pub fn touch(&mut self, kind: Kind, key: u128) -> bool {
|
||||
self.clock += 1;
|
||||
let c = self.clock;
|
||||
match self.map.get_mut(&(kind, key)) {
|
||||
Some(m) => {
|
||||
m.last_use = c;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget an entry; its size, if it was indexed.
|
||||
pub fn remove(&mut self, kind: Kind, key: u128) -> Option<u64> {
|
||||
let m = self.map.remove(&(kind, key))?;
|
||||
self.total -= m.bytes;
|
||||
Some(m.bytes)
|
||||
}
|
||||
|
||||
pub fn contains(&self, kind: Kind, key: u128) -> bool {
|
||||
self.map.contains_key(&(kind, key))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
/// Bytes of every indexed entry.
|
||||
pub fn total(&self) -> u64 {
|
||||
self.total
|
||||
}
|
||||
|
||||
/// Keys of one kind (unordered).
|
||||
pub fn keys(&self, kind: Kind) -> Vec<u128> {
|
||||
self.map.keys().filter(|k| k.0 == kind).map(|k| k.1).collect()
|
||||
}
|
||||
|
||||
/// Entries from least to most recently used.
|
||||
pub fn lru_order(&self) -> Vec<(Kind, u128)> {
|
||||
let mut v: Vec<(u64, (Kind, u128))> = self.map.iter().map(|(k, m)| (m.last_use, *k)).collect();
|
||||
v.sort_unstable_by_key(|e| e.0);
|
||||
v.into_iter().map(|e| e.1).collect()
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the total fits `max` bytes; returns them (the
|
||||
/// caller deletes their files).
|
||||
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
|
||||
if self.total <= max {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
for k in self.lru_order() {
|
||||
if self.total <= max {
|
||||
break;
|
||||
}
|
||||
self.remove(k.0, k.1);
|
||||
out.push(k);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
*self = DiskIndex::default();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
enum Job {
|
||||
Write(Kind, u128, Vec<u8>),
|
||||
|
|
@ -99,7 +185,7 @@ struct Queue {
|
|||
|
||||
pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
index: Mutex<Index>,
|
||||
index: Mutex<DiskIndex>,
|
||||
max_bytes: AtomicU64,
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
|
|
@ -172,7 +258,7 @@ impl DiskCache {
|
|||
std::fs::create_dir_all(&dir)?;
|
||||
let dc = Arc::new(DiskCache {
|
||||
dir,
|
||||
index: Mutex::new(Index::default()),
|
||||
index: Mutex::new(DiskIndex::default()),
|
||||
max_bytes: AtomicU64::new(max_bytes),
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
|
|
@ -223,12 +309,9 @@ impl DiskCache {
|
|||
}
|
||||
}
|
||||
found.sort_by_key(|e| e.0);
|
||||
let mut idx = Index::default();
|
||||
let mut idx = DiskIndex::default();
|
||||
for (_, kind, key, bytes) in found {
|
||||
idx.clock += 1;
|
||||
let clock = idx.clock;
|
||||
idx.total += bytes;
|
||||
idx.map.insert((kind, key), Meta { bytes, last_use: clock });
|
||||
idx.insert(kind, key, bytes);
|
||||
}
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
*g = idx;
|
||||
|
|
@ -260,16 +343,16 @@ impl DiskCache {
|
|||
}
|
||||
|
||||
pub fn contains(&self, kind: Kind, key: u128) -> bool {
|
||||
self.index.lock().is_ok_and(|g| g.map.contains_key(&(kind, key)))
|
||||
self.index.lock().is_ok_and(|g| g.contains(kind, key))
|
||||
}
|
||||
|
||||
/// Keys of one kind (unordered).
|
||||
pub fn keys(&self, kind: Kind) -> Vec<u128> {
|
||||
self.index.lock().map(|g| g.map.keys().filter(|k| k.0 == kind).map(|k| k.1).collect()).unwrap_or_default()
|
||||
self.index.lock().map(|g| g.keys(kind)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> DiskStats {
|
||||
let (entries, bytes) = self.index.lock().map(|g| (g.map.len(), g.total)).unwrap_or_default();
|
||||
let (entries, bytes) = self.index.lock().map(|g| (g.len(), g.total())).unwrap_or_default();
|
||||
DiskStats {
|
||||
entries,
|
||||
bytes,
|
||||
|
|
@ -286,7 +369,7 @@ impl DiskCache {
|
|||
pub fn clear(&self) {
|
||||
self.flush();
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
*g = Index::default();
|
||||
g.clear();
|
||||
}
|
||||
for kind in [Kind::Frame, Kind::Layer] {
|
||||
let _ = std::fs::remove_dir_all(self.dir.join(kind.dir()));
|
||||
|
|
@ -322,11 +405,7 @@ impl DiskCache {
|
|||
let payload = payload.to_vec();
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.clock += 1;
|
||||
let c = g.clock;
|
||||
if let Some(m) = g.map.get_mut(&(kind, key)) {
|
||||
m.last_use = c;
|
||||
}
|
||||
g.touch(kind, key);
|
||||
}
|
||||
// Persist the recency for the next session (best effort).
|
||||
if let Ok(f) = std::fs::File::options().append(true).open(&path) {
|
||||
|
|
@ -347,10 +426,8 @@ impl DiskCache {
|
|||
}
|
||||
|
||||
fn forget(&self, kind: Kind, key: u128) {
|
||||
if let Ok(mut g) = self.index.lock()
|
||||
&& let Some(m) = g.map.remove(&(kind, key))
|
||||
{
|
||||
g.total -= m.bytes;
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.remove(kind, key);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,13 +459,7 @@ impl DiskCache {
|
|||
if std::fs::create_dir_all(parent).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut data = Vec::with_capacity(payload.len() + 16);
|
||||
data.extend_from_slice(MAGIC);
|
||||
data.push(kind.tag());
|
||||
data.extend_from_slice(&[0, 0, 0]);
|
||||
data.extend_from_slice(payload);
|
||||
let sum = checksum(&data);
|
||||
data.extend_from_slice(&sum.to_le_bytes());
|
||||
let data = seal(kind, payload);
|
||||
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
||||
if std::fs::write(&tmp, &data).is_err() {
|
||||
|
|
@ -401,13 +472,7 @@ impl DiskCache {
|
|||
}
|
||||
self.writes.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.clock += 1;
|
||||
let c = g.clock;
|
||||
let bytes = data.len() as u64;
|
||||
if let Some(old) = g.map.insert((kind, key), Meta { bytes, last_use: c }) {
|
||||
g.total -= old.bytes;
|
||||
}
|
||||
g.total += bytes;
|
||||
g.insert(kind, key, data.len() as u64);
|
||||
}
|
||||
self.evict();
|
||||
}
|
||||
|
|
@ -415,23 +480,9 @@ impl DiskCache {
|
|||
/// Remove least recently used entries until the cache fits its limit.
|
||||
fn evict(&self) {
|
||||
let max = self.max_bytes();
|
||||
let victims: Vec<(Kind, u128)> = {
|
||||
let Ok(mut g) = self.index.lock() else { return };
|
||||
if g.total <= max {
|
||||
return;
|
||||
}
|
||||
let mut by_age: Vec<(u64, (Kind, u128), u64)> = g.map.iter().map(|(k, m)| (m.last_use, *k, m.bytes)).collect();
|
||||
by_age.sort_unstable_by_key(|e| e.0);
|
||||
let mut out = vec![];
|
||||
for (_, k, bytes) in by_age {
|
||||
if g.total <= max {
|
||||
break;
|
||||
}
|
||||
g.map.remove(&k);
|
||||
g.total -= bytes;
|
||||
out.push(k);
|
||||
}
|
||||
out
|
||||
let victims = match self.index.lock() {
|
||||
Ok(mut g) => g.evict(max),
|
||||
Err(_) => return,
|
||||
};
|
||||
for (kind, key) in victims {
|
||||
let _ = std::fs::remove_file(self.path(kind, key));
|
||||
|
|
@ -510,6 +561,40 @@ fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// An entry file's bytes: header (magic, kind), payload, checksum.
|
||||
pub fn seal(kind: Kind, payload: &[u8]) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(payload.len() + 16);
|
||||
data.extend_from_slice(MAGIC);
|
||||
data.push(kind.tag());
|
||||
data.extend_from_slice(&[0, 0, 0]);
|
||||
data.extend_from_slice(payload);
|
||||
let sum = checksum(&data);
|
||||
data.extend_from_slice(&sum.to_le_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
/// A sealed frame entry ([`seal`] of the encoded frame): what the browser's frame workers
|
||||
/// write to the Origin Private File System.
|
||||
pub fn frame_entry(width: u32, height: u32, rgba: &[u8]) -> Vec<u8> {
|
||||
seal(Kind::Frame, &encode_frame(width, height, rgba))
|
||||
}
|
||||
|
||||
/// A frame from a sealed entry's bytes (`None` when damaged).
|
||||
pub fn read_frame_entry(data: &[u8]) -> Option<Frame8> {
|
||||
decode_frame(verify(data, Kind::Frame)?)
|
||||
}
|
||||
|
||||
/// An entry's file name in its kind's folder (`<32 hex>.ecc`).
|
||||
pub fn entry_name(key: u128) -> String {
|
||||
format!("{key:032x}.ecc")
|
||||
}
|
||||
|
||||
/// The key of an entry file name (`None` for temporaries and other files).
|
||||
pub fn parse_entry_name(name: &str) -> Option<u128> {
|
||||
let hex = name.strip_suffix(".ecc")?;
|
||||
(hex.len() == 32).then(|| u128::from_str_radix(hex, 16).ok()).flatten()
|
||||
}
|
||||
|
||||
/// Check magic, kind and checksum; returns the payload.
|
||||
fn verify(d: &[u8], kind: Kind) -> Option<&[u8]> {
|
||||
if d.len() < 16 || &d[..4] != MAGIC || d[4] != kind.tag() {
|
||||
|
|
@ -707,6 +792,40 @@ mod tests {
|
|||
Buf { img, offset: [1.5, -2.25], scale: 0.5 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_evicts_least_recently_used() {
|
||||
let mut ix = DiskIndex::default();
|
||||
ix.insert(Kind::Frame, 1, 100);
|
||||
ix.insert(Kind::Frame, 2, 100);
|
||||
ix.insert(Kind::Layer, 3, 100);
|
||||
assert_eq!((ix.len(), ix.total()), (3, 300));
|
||||
assert!(ix.touch(Kind::Frame, 1));
|
||||
assert!(!ix.touch(Kind::Frame, 3));
|
||||
assert_eq!(ix.lru_order(), vec![(Kind::Frame, 2), (Kind::Layer, 3), (Kind::Frame, 1)]);
|
||||
assert_eq!(ix.evict(250), vec![(Kind::Frame, 2)]);
|
||||
assert!(ix.evict(250).is_empty());
|
||||
ix.insert(Kind::Frame, 1, 50);
|
||||
assert_eq!(ix.total(), 150);
|
||||
assert_eq!(ix.remove(Kind::Layer, 3), Some(100));
|
||||
assert_eq!(ix.keys(Kind::Frame), vec![1]);
|
||||
ix.clear();
|
||||
assert!(ix.is_empty() && ix.total() == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_entries_round_trip_and_names_parse() {
|
||||
let rgba: Vec<u8> = (0..4 * 6 * 3).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let e = frame_entry(6, 3, &rgba);
|
||||
assert_eq!(read_frame_entry(&e), Some(Frame8 { width: 6, height: 3, rgba: rgba.clone() }));
|
||||
let mut bad = e.clone();
|
||||
bad[20] ^= 1;
|
||||
assert!(read_frame_entry(&bad).is_none());
|
||||
let key = 0x0123_4567_89ab_cdef_0011_2233_4455_6677u128;
|
||||
assert_eq!(parse_entry_name(&entry_name(key)), Some(key));
|
||||
assert_eq!(parse_entry_name("abc.ecc"), None);
|
||||
assert_eq!(parse_entry_name(&format!("{key:032x}.tmp.1")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_miss_and_exact_round_trip() {
|
||||
let dir = tmpdir("hit");
|
||||
|
|
|
|||
|
|
@ -296,6 +296,23 @@ pub trait Accelerator: Send + Sync {
|
|||
fn auto_pick(&self) -> Option<&AutoPick> {
|
||||
None
|
||||
}
|
||||
/// Deferred readbacks: where a GPU readback cannot be waited for (WebGPU in a browser
|
||||
/// worker), a frame renders in *passes*. A readback the accelerator does not have yet is
|
||||
/// started, the step returns a cheap placeholder and the pass is marked missed
|
||||
/// ([`Self::pass_missed`]); the caller waits for the readbacks in flight (asynchronously)
|
||||
/// and renders the frame again, and the steps whose results arrived are served from them.
|
||||
/// The frame is done after a pass with no miss. `frame_begin` forgets the previous frame's
|
||||
/// results, `pass_begin` clears the miss. Synchronous accelerators never miss.
|
||||
fn frame_begin(&self) {}
|
||||
fn pass_begin(&self) {}
|
||||
fn pass_missed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Raised from a pass's first miss to its end (results computed meanwhile may hold
|
||||
/// placeholders): give it to [`LayerCache::set_gate`] so they are not cached.
|
||||
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image
|
||||
|
|
@ -1606,6 +1623,25 @@ impl<'a> Renderer<'a> {
|
|||
three_d::adv::prepare_run(self, ctx, run, out)
|
||||
}
|
||||
|
||||
/// A run of consecutive 3D layers of an Advanced 3D comp whose layers need the 2D
|
||||
/// compositing path (blend mode, track matte, Preserve Transparency), split for an
|
||||
/// accelerator's compositor ([`three_d::adv::SplitRun`]). `None` = [`Self::prepare_adv_run`]
|
||||
/// covers it, or it isn't an Advanced 3D run.
|
||||
pub fn split_adv_run<'p>(&'p self, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<three_d::adv::SplitRun<'p, 'a>> {
|
||||
three_d::adv::split_run(self, ctx, run, out)
|
||||
}
|
||||
|
||||
/// Whether the comp's 3D layers draw with the Advanced 3D renderer.
|
||||
pub fn is_adv3d(&self, ctx: &EvalCtx) -> bool {
|
||||
three_d::adv::active(self, ctx)
|
||||
}
|
||||
|
||||
/// An Environment Light Background layer ready to draw on an accelerator
|
||||
/// ([`three_d::SkyDraw`]; the CPU draws it in `draw_3d_run`). `None` = nothing to draw.
|
||||
pub fn sky_draw(&self, ctx: &EvalCtx, layer: &Layer) -> Option<three_d::SkyDraw> {
|
||||
three_d::compose::sky_draw(self, ctx, layer)
|
||||
}
|
||||
|
||||
/// The pixels a Wireframe-quality layer sets (white, opaque) on a `size` canvas: its
|
||||
/// bounds' outline, one pixel wide. Empty when the layer has no bounds.
|
||||
pub fn wireframe_pixels(&self, ctx: &EvalCtx, layer: &Layer, size: (u32, u32)) -> Vec<(u32, u32)> {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
//! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2
|
||||
//! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's
|
||||
//! circle of confusion, shaped by the camera's iris: [`dof`]) and the conversion back to the working encoding run on the CPU for
|
||||
//! both paths, and the result is composited over the layers below the run.
|
||||
//! both paths, and the result is composited over the layers below the run. An accelerator's
|
||||
//! compositor runs all of it on the device: [`Prepared`] for whole runs, [`SplitRun`] for runs
|
||||
//! with layers on the 2D compositing path (`draw_run`'s steps as data).
|
||||
|
||||
pub mod dof;
|
||||
pub mod raster;
|
||||
|
|
@ -217,7 +219,7 @@ pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out:
|
|||
|
||||
/// The run as one prepared scene for an accelerator's compositor when it can be composited
|
||||
/// directly over the canvas (Normal-mode layers without track mattes or Preserve Transparency;
|
||||
/// those go through [`draw_run`]'s 2D path; environment backgrounds draw their sky first). `None`
|
||||
/// those go through `draw_run`'s 2D path; environment backgrounds draw their sky first). `None`
|
||||
/// when the comp isn't Advanced 3D.
|
||||
pub(crate) fn prepare_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<Prepared<'p, 'a>> {
|
||||
if !active(r, ctx) || run.iter().any(|l| l.environment_background || needs_2d_composite(ctx, l)) {
|
||||
|
|
@ -226,6 +228,102 @@ pub(crate) fn prepare_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run
|
|||
Some(Prepared::new(r, ctx, run, out))
|
||||
}
|
||||
|
||||
/// A run of an Advanced 3D comp whose layers need the 2D compositing path (blend modes, track
|
||||
/// mattes, Preserve Transparency), prepared for an accelerator's compositor: `draw_run`'s
|
||||
/// steps as data. The plain layers render as one scene ([`SplitRun::main`]) composited over the
|
||||
/// canvas; then each special layer, from the farthest to the nearest, renders on its own
|
||||
/// ([`SplitRun::special`]), is hidden where the main scene is nearer, and goes through the 2D
|
||||
/// path with its track matte (a 3D matte layer drawn through the camera:
|
||||
/// [`SplitRun::with_matte3d`]).
|
||||
pub struct SplitRun<'p, 'a> {
|
||||
r: &'p Renderer<'a>,
|
||||
ctx: &'p EvalCtx<'a>,
|
||||
out: (u32, u32),
|
||||
plain: Vec<&'p Layer>,
|
||||
/// Far to near.
|
||||
special: Vec<&'p Layer>,
|
||||
/// Per special layer: its 3D track matte drawn solo (and whether it is active now).
|
||||
mattes: Vec<Option<(Layer, bool)>>,
|
||||
}
|
||||
|
||||
impl<'p, 'a> SplitRun<'p, 'a> {
|
||||
/// The plain layers as one scene.
|
||||
pub fn main(&self) -> Prepared<'_, 'a> {
|
||||
Prepared::new(self.r, self.ctx, &self.plain, self.out)
|
||||
}
|
||||
|
||||
/// Number of special layers.
|
||||
pub fn len(&self) -> usize {
|
||||
self.special.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.special.is_empty()
|
||||
}
|
||||
|
||||
/// Special layer `i` (far to near).
|
||||
pub fn layer(&self, i: usize) -> &'p Layer {
|
||||
self.special[i]
|
||||
}
|
||||
|
||||
/// Special layer `i` rendered on its own.
|
||||
pub fn special(&self, i: usize) -> Prepared<'_, 'a> {
|
||||
Prepared::new(self.r, self.ctx, std::slice::from_ref(&self.special[i]), self.out)
|
||||
}
|
||||
|
||||
/// `f` with special layer `i`'s 3D track matte as a prepared run (`None` when the matte
|
||||
/// layer isn't active: a transparent matte). `None` when the layer has no 3D matte (its
|
||||
/// matte, if any, is placed in 2D as usual).
|
||||
pub fn with_matte3d<R>(&self, i: usize, f: impl FnOnce(Option<&Prepared<'_, 'a>>) -> R) -> Option<R> {
|
||||
let (solo, active) = self.mattes[i].as_ref()?;
|
||||
if !*active {
|
||||
return Some(f(None));
|
||||
}
|
||||
let layers = [solo];
|
||||
let p = Prepared::new(self.r, self.ctx, &layers, self.out);
|
||||
Some(f(Some(&p)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The run split for an accelerator when some layer needs the 2D compositing path (`None` when
|
||||
/// the comp isn't Advanced 3D, a layer is an environment background, or no layer needs it:
|
||||
/// `prepare_run` then covers the run).
|
||||
pub(crate) fn split_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<SplitRun<'p, 'a>> {
|
||||
if !active(r, ctx) || run.iter().any(|l| l.environment_background) || !run.iter().any(|l| needs_2d_composite(ctx, l)) {
|
||||
return None;
|
||||
}
|
||||
let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l));
|
||||
let special = far_to_near(r, ctx, special);
|
||||
let mattes = special.iter().map(|l| solo_matte(ctx, l)).collect();
|
||||
Some(SplitRun { r, ctx, out, plain, special, mattes })
|
||||
}
|
||||
|
||||
/// Special layers sorted by their anchor's camera depth, farthest first.
|
||||
fn far_to_near<'p>(r: &Renderer, ctx: &EvalCtx, special: Vec<&'p Layer>) -> Vec<&'p Layer> {
|
||||
let cam = camera_for(r, ctx);
|
||||
let mut order: Vec<(f64, &Layer)> = special
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3]));
|
||||
(cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l)
|
||||
})
|
||||
.collect();
|
||||
order.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
order.into_iter().map(|(_, l)| l).collect()
|
||||
}
|
||||
|
||||
/// A layer's 3D track matte, drawn through the camera on its own (no matte, Normal mode, no
|
||||
/// Preserve Transparency, visible), and whether it is active now.
|
||||
fn solo_matte(ctx: &EvalCtx, l: &Layer) -> Option<(Layer, bool)> {
|
||||
let m = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d()))?;
|
||||
let mut solo = m.clone();
|
||||
solo.track_matte = None;
|
||||
solo.blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
solo.preserve_transparency = false;
|
||||
solo.switches.video = true;
|
||||
Some((solo, m.is_active_at(ctx.time)))
|
||||
}
|
||||
|
||||
/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path.
|
||||
fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool {
|
||||
l.blend_mode != effectcraft_color::BlendMode::Normal
|
||||
|
|
@ -252,16 +350,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
|
|||
if special.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cam = camera_for(r, ctx);
|
||||
let mut order: Vec<(f64, &Layer)> = special
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3]));
|
||||
(cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l)
|
||||
})
|
||||
.collect();
|
||||
order.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
for (_, l) in order {
|
||||
for l in far_to_near(r, ctx, special) {
|
||||
let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue };
|
||||
if let Some((m, md)) = &main {
|
||||
iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| {
|
||||
|
|
@ -274,17 +363,8 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
|
|||
});
|
||||
}
|
||||
// A 3D matte layer is drawn through the camera too.
|
||||
let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| {
|
||||
let mut solo = m.clone();
|
||||
solo.track_matte = None;
|
||||
solo.blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
solo.preserve_transparency = false;
|
||||
solo.switches.video = true;
|
||||
if m.is_active_at(ctx.time) {
|
||||
render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1))
|
||||
} else {
|
||||
Image::new(out.0, out.1)
|
||||
}
|
||||
let matte3d = solo_matte(ctx, l).map(|(solo, active)| {
|
||||
if active { render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1)) } else { Image::new(out.0, out.1) }
|
||||
});
|
||||
r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -971,30 +971,71 @@ pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
|
|||
.to_radians() as f32
|
||||
}
|
||||
|
||||
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
|
||||
/// pixel's view direction through the camera, as an infinitely distant backdrop.
|
||||
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return };
|
||||
let img = &buf.img;
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return;
|
||||
/// An Environment Light Background layer ready to draw (see [`draw_sky`]): the layer's
|
||||
/// buffer in the blending space, its opacity and the camera's view rays. Also handed to an
|
||||
/// accelerator's compositor (`Renderer::sky_draw`).
|
||||
#[derive(Clone)]
|
||||
pub struct SkyDraw {
|
||||
pub buf: Arc<Buf>,
|
||||
pub opacity: f32,
|
||||
/// Camera basis: forward, right, down.
|
||||
pub fwd: [f64; 3],
|
||||
pub right: [f64; 3],
|
||||
pub down: [f64; 3],
|
||||
pub zoom: f64,
|
||||
pub ortho: bool,
|
||||
/// View size (comp pixels), region-of-interest offset (output pixels), output scale.
|
||||
pub view: (f64, f64),
|
||||
pub roi: (f64, f64),
|
||||
pub scale: f64,
|
||||
/// The Environment light's rotation (radians).
|
||||
pub rotation: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn sky_draw(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<SkyDraw> {
|
||||
let buf = r.blend_layer_buf(ctx, layer)?;
|
||||
if buf.img.width == 0 || buf.img.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
|
||||
if op <= 0.0 {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let cam = camera_for(r, ctx);
|
||||
let (fwd, right, down) = (cam.forward(), cam.right(), cam.down());
|
||||
let s = r.opts.scale.max(1e-9);
|
||||
let (cw, ch) = view_size(r, ctx);
|
||||
let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y));
|
||||
let rot = environment_rotation(ctx);
|
||||
let v3 = |v: Vec3| [v.x, v.y, v.z];
|
||||
Some(SkyDraw {
|
||||
buf,
|
||||
opacity: op,
|
||||
fwd: v3(cam.forward()),
|
||||
right: v3(cam.right()),
|
||||
down: v3(cam.down()),
|
||||
zoom: cam.zoom,
|
||||
ortho: cam.ortho,
|
||||
view: view_size(r, ctx),
|
||||
roi: r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)),
|
||||
scale: r.opts.scale.max(1e-9),
|
||||
rotation: environment_rotation(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
|
||||
/// pixel's view direction through the camera, as an infinitely distant backdrop.
|
||||
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let Some(sky) = sky_draw(r, ctx, layer) else { return };
|
||||
let img = &sky.buf.img;
|
||||
let op = sky.opacity;
|
||||
let v3 = |v: [f64; 3]| vec3(v[0], v[1], v[2]);
|
||||
let (fwd, right, down) = (v3(sky.fwd), v3(sky.right), v3(sky.down));
|
||||
let s = sky.scale;
|
||||
let (cw, ch) = sky.view;
|
||||
let (ox, oy) = sky.roi;
|
||||
let rot = sky.rotation;
|
||||
let (iw, ih) = (img.width as f64, img.height as f64);
|
||||
canvas.rows_mut().for_each(|(y, row)| {
|
||||
let cy = (y as f64 + 0.5 + oy) / s;
|
||||
for (x, d) in row.iter_mut().enumerate() {
|
||||
let cx = (x as f64 + 0.5 + ox) / s;
|
||||
let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
|
||||
let dir = if sky.ortho { fwd } else { (fwd * sky.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
|
||||
let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot);
|
||||
// Wrap horizontally across the seam.
|
||||
let sx = (u as f64 * iw).clamp(0.5, iw - 0.5);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ mod tests;
|
|||
mod tests_orient;
|
||||
|
||||
pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam};
|
||||
pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d};
|
||||
pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d, SkyDraw};
|
||||
pub use light::{LightState, Material, lights_at};
|
||||
|
||||
use effectcraft_geom::Mat3;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,9 @@ pub struct RemoteJob {
|
|||
pub comp: ItemId,
|
||||
pub t: Tick,
|
||||
pub opts: RenderOpts,
|
||||
/// The frame's disk-cache key, when the renderer keeps a disk cache
|
||||
/// ([`RemoteFrames::disk`]): served from it on a hit, stored there after a render.
|
||||
pub disk_key: Option<u128>,
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA8 pixels from a [`RemoteFrames`] renderer.
|
||||
|
|
@ -195,6 +198,31 @@ pub trait RemoteFrames: Send + Sync {
|
|||
fn start(&self, job: RemoteJob, done: RemoteDone);
|
||||
/// Drop cached intermediate results (Edit ▸ Purge).
|
||||
fn purge(&self) {}
|
||||
/// It renders GPU effects (its own GPU device): frames with effects go to it rather than
|
||||
/// the UI thread's GPU compositor, which would run their effects on the CPU.
|
||||
fn gpu(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// It keeps a disk cache of frames (the browser's Origin Private File System).
|
||||
fn disk(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The disk cache holds the frame of this key (the blue cache bar).
|
||||
fn disk_contains(&self, _key: u128) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a comp (or a comp it nests) has a layer with enabled effects.
|
||||
pub fn comp_has_effects(project: &Project, comp: ItemId) -> bool {
|
||||
fn walk(p: &Project, comp: ItemId, depth: usize) -> bool {
|
||||
let Some(c) = p.comp(comp) else { return false };
|
||||
c.layers.iter().any(|l| {
|
||||
l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled))
|
||||
|| depth < 16 && matches!(l.source, effectcraft_engine::project::LayerSource::Comp { item } if walk(p, item, depth + 1))
|
||||
})
|
||||
}
|
||||
walk(project, comp, 0)
|
||||
}
|
||||
|
||||
pub struct Frames {
|
||||
|
|
@ -283,14 +311,22 @@ impl Frames {
|
|||
/// Frames of (revision, comp, scale) in the disk cache but not in RAM — the blue cache bar.
|
||||
/// `opts` are the viewer's render options (part of the disk key).
|
||||
pub fn disk_frames(&self, src: &RenderSource, revision: u64, comp: u64, scale: u32, view: u64, frames: i64, opts: &RenderOpts) -> Vec<i64> {
|
||||
let Some(dc) = &src.disk else { return vec![] };
|
||||
let remote = self.remote.as_ref().filter(|r| r.disk());
|
||||
let contains = |k: u128| match (&src.disk, remote) {
|
||||
(Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k),
|
||||
(None, Some(r)) => r.disk_contains(k),
|
||||
(None, None) => false,
|
||||
};
|
||||
if src.disk.is_none() && remote.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let ram: HashSet<i64> = self.cached_frames(revision, comp, scale).into_iter().collect();
|
||||
let oh = opts_hash(opts);
|
||||
(0..frames.clamp(0, 100_000))
|
||||
.filter(|f| !ram.contains(f))
|
||||
.filter(|f| {
|
||||
let key = FrameKey { revision, comp, frame: *f, scale, view };
|
||||
dc.contains(disk_cache::Kind::Frame, frame_disk_key(&self.content_keys, &src.project, &key, oh))
|
||||
contains(frame_disk_key(&self.content_keys, &src.project, &key, oh))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -396,6 +432,11 @@ impl Frames {
|
|||
self.remote = remote;
|
||||
}
|
||||
|
||||
/// The remote renderer keeps a disk cache (the browser's).
|
||||
pub fn remote_disk(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| r.disk())
|
||||
}
|
||||
|
||||
/// Whether frames go to a usable [`RemoteFrames`].
|
||||
pub fn remote_active(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| r.slots() > 0)
|
||||
|
|
@ -435,7 +476,11 @@ impl Frames {
|
|||
}
|
||||
job
|
||||
};
|
||||
if job.src.gpu.is_some() && job.src.gpu_display {
|
||||
// The GPU compositor here keeps the frame on the GPU, but runs layer effects on the
|
||||
// CPU (this thread can't wait for readbacks): frames with effects go to workers
|
||||
// that render them on their own GPU.
|
||||
let gpu_here = job.src.gpu.is_some() && job.src.gpu_display && !(remote.gpu() && comp_has_effects(&job.src.project, job.comp));
|
||||
if gpu_here {
|
||||
let t0 = web_time::Instant::now();
|
||||
if let Some(img) = w.render_gpu(&job) {
|
||||
w.finish(&job, img, t0);
|
||||
|
|
@ -450,7 +495,8 @@ impl Frames {
|
|||
let fw = self.worker();
|
||||
let busy = self.remote_busy.clone();
|
||||
let t0 = web_time::Instant::now();
|
||||
let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts };
|
||||
let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts)));
|
||||
let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key };
|
||||
remote.start(
|
||||
rj,
|
||||
Box::new(move |r: Result<RemoteFrame, String>| {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
|||
}
|
||||
}
|
||||
Item::Labels => labels_ui(app, ui, cur, acts),
|
||||
Item::BrowserStorage => browser_storage_ui(app, ui, t, acts),
|
||||
Item::AudioDevices { key } => {
|
||||
let devices: Vec<String> = app.hooks.audio_devices.as_ref().map(|f| f()).unwrap_or_default();
|
||||
let sel = get(key).as_str().unwrap_or("").to_string();
|
||||
|
|
@ -193,6 +194,93 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
|||
}
|
||||
}
|
||||
|
||||
/// Bytes as "12.3 MB".
|
||||
pub fn human_bytes(b: u64) -> String {
|
||||
let units = ["bytes", "KB", "MB", "GB", "TB"];
|
||||
let mut v = b as f64;
|
||||
let mut u = 0;
|
||||
while v >= 1000.0 && u + 1 < units.len() {
|
||||
v /= 1000.0;
|
||||
u += 1;
|
||||
}
|
||||
if u == 0 { format!("{b} bytes") } else { format!("{v:.1} {}", units[u]) }
|
||||
}
|
||||
|
||||
/// Settings ▸ Disk ▸ Browser Storage (the web app's storage manager, `storage.*`): where the
|
||||
/// data lives, the origin's usage and quota, persistent storage, and Clear buttons.
|
||||
fn browser_storage_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut Vec<Act>) {
|
||||
let Some(host) = app.session.storage.clone() else { return };
|
||||
let info = host.info();
|
||||
let u = |v: &Value| v.as_u64().unwrap_or(0);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("Browser Storage").font(Tokens::semibold(12.5)).color(t.tab_text_active));
|
||||
ui.separator();
|
||||
let backend = match info["backend"].as_str().unwrap_or("") {
|
||||
"opfs" => "Origin Private File System",
|
||||
"indexeddb" => "IndexedDB",
|
||||
"memory" => "Memory only: nothing is kept after a reload",
|
||||
"" => "Unknown",
|
||||
other => other,
|
||||
};
|
||||
let r = ui.label(format!("Stored in: {backend}"));
|
||||
reg(app, "settings.storage.backend", &r, backend);
|
||||
let (usage, quota) = (u(&info["usage"]), u(&info["quota"]));
|
||||
let frac = if quota > 0 { usage as f32 / quota as f32 } else { 0.0 };
|
||||
let text = if quota > 0 { format!("{} of {} used ({:.1} %)", human_bytes(usage), human_bytes(quota), frac * 100.0) } else { "Usage unknown".into() };
|
||||
let r = ui
|
||||
.horizontal(|ui| {
|
||||
ui.add(egui::ProgressBar::new(frac.clamp(0.0, 1.0)).desired_width(220.0).desired_height(8.0));
|
||||
ui.label(&text);
|
||||
})
|
||||
.response;
|
||||
reg(app, "settings.storage.usage", &r, &text);
|
||||
let f = &info["files"];
|
||||
let row = |what: &str, k: &str| format!("{what}: {} ({})", u(&f[k]["count"]), human_bytes(u(&f[k]["bytes"])));
|
||||
ui.label(
|
||||
RichText::new(format!("{} · {} · {}", row("Projects", "projects"), row("Imported media", "media"), row("Auto-saves", "autoSaves"))).color(t.text_dim),
|
||||
);
|
||||
let d = &info["diskCache"];
|
||||
let dc = if d["enabled"].as_bool().unwrap_or(false) {
|
||||
format!(
|
||||
"Disk cache: {} frames, {} of {} (hits {}, misses {})",
|
||||
u(&d["entries"]),
|
||||
human_bytes(u(&d["bytes"])),
|
||||
human_bytes(u(&d["maxBytes"])),
|
||||
u(&d["hits"]),
|
||||
u(&d["misses"])
|
||||
)
|
||||
} else {
|
||||
"Disk cache: off".into()
|
||||
};
|
||||
let r = ui.label(RichText::new(&dc).color(t.text_dim));
|
||||
reg(app, "settings.storage.diskCache", &r, &dc);
|
||||
ui.horizontal(|ui| {
|
||||
let persisted = info["persisted"].as_bool().unwrap_or(false);
|
||||
let state = if persisted { "Persistent storage: granted" } else { "Persistent storage: not granted (the browser may clear it when space runs low)" };
|
||||
ui.label(state);
|
||||
if !persisted {
|
||||
let r = ui.button("Request Persistent Storage");
|
||||
reg(app, "settings.storage.persist", &r, "Request Persistent Storage");
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run("storage.persist".into(), json!({})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (what, label) in
|
||||
[("diskCache", "Clear Disk Cache"), ("media", "Clear Imported Media"), ("autoSaves", "Clear Auto-Saves"), ("projects", "Clear Saved Projects")]
|
||||
{
|
||||
let r = ui.button(label);
|
||||
reg(app, &format!("settings.storage.clear.{what}"), &r, label);
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run("storage.clear".into(), json!({"what": what})));
|
||||
}
|
||||
}
|
||||
});
|
||||
// The numbers refresh in the background.
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
|
||||
ui.label("Label Colors and Names:");
|
||||
ui.add_space(4.0);
|
||||
|
|
|
|||
|
|
@ -957,7 +957,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
draw_run(a, b);
|
||||
}
|
||||
// Disk-cached frames not in RAM (blue), like After Effects.
|
||||
if app.session.disk_cache.is_some() {
|
||||
if app.session.disk_cache.is_some() || app.frames.remote_disk() {
|
||||
let frames = comp.frame_rate.frame_at(comp.duration);
|
||||
let opts = app.frame_opts(cid, scale_key as f64 / 1000.0);
|
||||
let disk = app.frames.disk_frames(&app.render_source(), app.session.revision, cid.0, scale_key, app.view_hash(cid), frames, &opts);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
//! Headless checks for M13.24 (web app depth: GPU frame workers, the browser's disk cache, the
|
||||
//! storage manager), run natively with stand-ins for the browser:
|
||||
//!
|
||||
//! - a frame worker with its own GPU device and deferred readbacks (the browser worker's
|
||||
//! WebGPU path) renders frames with GPU effects in passes; the page's own GPU compositor
|
||||
//! keeps frames without effects, and frames with effects go to the worker;
|
||||
//! - frames the worker rendered are stored under their disk-cache keys (an in-memory stand-in
|
||||
//! for the Origin Private File System) and served from there after a "reload" (a new frame
|
||||
//! cache), without rendering; the blue cache bar sees them;
|
||||
//! - Settings ▸ Disk shows the browser storage manager when the session has a storage host,
|
||||
//! and its buttons run `storage.*` commands.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror};
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_engine::render::disk_cache;
|
||||
use effectcraft_engine::storage::StorageHost;
|
||||
use effectcraft_gpu::Gpu;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
|
||||
use effectcraft_ui_egui::frames::{FrameImage, FrameKey, Frames, RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, RenderSource};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rt<T: serde::Serialize + for<'de> serde::Deserialize<'de>>(v: &T) -> T {
|
||||
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// A GPU frame worker in this process, with a "disk" shared across reloads.
|
||||
struct GpuWorker {
|
||||
gpu: Gpu,
|
||||
state: Mutex<(Mirror, FrameServer, u64)>,
|
||||
disk: Arc<Mutex<HashMap<u128, Vec<u8>>>>,
|
||||
rendered: Mutex<Vec<(u32, bool)>>,
|
||||
}
|
||||
|
||||
impl GpuWorker {
|
||||
fn new(s: &Session, gpu: Gpu, disk: Arc<Mutex<HashMap<u128, Vec<u8>>>>) -> GpuWorker {
|
||||
let mut server = FrameServer::new(s.footage.clone(), s.expr.clone());
|
||||
server.set_accel(Some(Arc::new(gpu.clone())));
|
||||
GpuWorker { gpu, state: Mutex::new((Mirror::default(), server, 0)), disk, rendered: Mutex::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteFrames for GpuWorker {
|
||||
fn slots(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn gpu(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn disk(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn disk_contains(&self, key: u128) -> bool {
|
||||
self.disk.lock().unwrap().contains_key(&key)
|
||||
}
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone) {
|
||||
// The page reads a hit from the disk cache instead of asking the worker.
|
||||
if let Some(k) = job.disk_key
|
||||
&& let Some(f) = self.disk.lock().unwrap().get(&k).and_then(|e| disk_cache::read_frame_entry(e))
|
||||
{
|
||||
return done(Ok(RemoteFrame { width: f.width, height: f.height, rgba: f.rgba, ms: 0.0 }));
|
||||
}
|
||||
let mut st = self.state.lock().unwrap();
|
||||
let (mirror, server, next) = &mut *st;
|
||||
if let Some(m) = mirror.sync(job.revision, &job.project) {
|
||||
assert!(server.handle(rt(&m)).is_empty());
|
||||
}
|
||||
*next += 1;
|
||||
let disk = job.disk_key.map(|k| format!("{k:032x}"));
|
||||
let mut out = server.handle(rt(&FrameMsg::Render {
|
||||
id: *next,
|
||||
revision: job.revision,
|
||||
comp: job.comp,
|
||||
time: job.t,
|
||||
opts: Box::new(job.opts),
|
||||
disk: disk.clone(),
|
||||
}));
|
||||
// The worker awaits its device between passes (`Gpu::settled`); natively a poll.
|
||||
while server.waiting() {
|
||||
self.gpu.wait();
|
||||
pollster::block_on(self.gpu.settled());
|
||||
out = server.resume();
|
||||
}
|
||||
match out.into_iter().next() {
|
||||
Some((FrameReply::Frame { width, height, ms, passes, gpu, .. }, Some(rgba))) => {
|
||||
self.rendered.lock().unwrap().push((passes, gpu));
|
||||
if let Some(k) = job.disk_key {
|
||||
self.disk.lock().unwrap().insert(k, disk_cache::frame_entry(width, height, &rgba));
|
||||
}
|
||||
done(Ok(RemoteFrame { width, height, rgba, ms }))
|
||||
}
|
||||
other => done(Err(format!("{:?}", other.map(|o| o.0)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source(s: &Session, gpu: Option<Gpu>) -> RenderSource {
|
||||
RenderSource {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
layer_cache: s.layer_cache.clone(),
|
||||
gpu,
|
||||
gpu_display: true,
|
||||
disk: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_pixels(f: &FrameImage) -> Vec<egui::Color32> {
|
||||
match f {
|
||||
FrameImage::Cpu(c) => c.pixels.clone(),
|
||||
FrameImage::Gpu(_) => panic!("expected a CPU frame"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
|
||||
let (Some(page_gpu), Some(worker_gpu)) = (Gpu::headless(), Gpu::headless_deferred()) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Fx", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let plain = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Blurred", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let fx = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8030", "width": 80, "height": 40})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
|
||||
let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Gpu, ..Default::default() };
|
||||
let t = effectcraft_engine::time::Tick::ZERO;
|
||||
let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0 };
|
||||
let disk = Arc::new(Mutex::new(HashMap::new()));
|
||||
let worker = Arc::new(GpuWorker::new(&s, worker_gpu.clone(), disk.clone()));
|
||||
let mut frames = Frames::default();
|
||||
frames.set_remote(Some(worker.clone()));
|
||||
// No effects: the page's GPU compositor keeps the frame on the GPU.
|
||||
frames.request_urgent(&source(&s, Some(page_gpu.clone())), key(plain), plain, t, opts);
|
||||
frames.dispatch_remote();
|
||||
assert!(matches!(frames.get(&key(plain)), Some(FrameImage::Gpu(_))));
|
||||
assert!(worker.rendered.lock().unwrap().is_empty());
|
||||
// GPU effects: the worker renders the frame on its GPU, in passes.
|
||||
frames.request_urgent(&source(&s, Some(page_gpu.clone())), key(fx), fx, t, opts);
|
||||
frames.dispatch_remote();
|
||||
let got = cpu_pixels(&frames.get(&key(fx)).expect("rendered by the worker"));
|
||||
let (passes, on_gpu) = worker.rendered.lock().unwrap()[0];
|
||||
assert!(on_gpu && passes >= 2, "passes {passes}");
|
||||
let want = effectcraft_ui_egui::frames::to_color_image(&s.render(fx, t, RenderOpts { backend: effectcraft_engine::render::Backend::Cpu, ..opts }));
|
||||
let worst = got.iter().zip(&want.pixels).map(|(a, b)| (0..4).map(|c| (a[c] as i32 - b[c] as i32).abs()).max().unwrap()).max().unwrap();
|
||||
assert!(worst <= 2, "GPU vs CPU: {worst}/255");
|
||||
assert_eq!(disk.lock().unwrap().len(), 1, "stored under its disk key");
|
||||
// "Reload": a new frame cache and worker; the frame comes from the disk cache.
|
||||
let worker2 = Arc::new(GpuWorker::new(&s, worker_gpu, disk.clone()));
|
||||
let mut frames2 = Frames::default();
|
||||
frames2.set_remote(Some(worker2.clone()));
|
||||
assert!(frames2.remote_disk());
|
||||
let bar = frames2.disk_frames(&source(&s, None), s.revision, fx.0, 1000, 0, 30, &opts);
|
||||
assert_eq!(bar, vec![0], "the blue cache bar shows the stored frame");
|
||||
frames2.request_urgent(&source(&s, Some(page_gpu)), key(fx), fx, t, opts);
|
||||
frames2.dispatch_remote();
|
||||
assert_eq!(cpu_pixels(&frames2.get(&key(fx)).unwrap()), got);
|
||||
assert!(worker2.rendered.lock().unwrap().is_empty(), "a hit is not rendered again");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Storage {
|
||||
cleared: Mutex<Vec<String>>,
|
||||
persist_asked: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl StorageHost for Storage {
|
||||
fn info(&self) -> Value {
|
||||
json!({"backend": "opfs", "usage": 52_000_000u64, "quota": 2_000_000_000u64, "persisted": false,
|
||||
"files": {"projects": {"count": 2, "bytes": 40000}, "media": {"count": 3, "bytes": 50_000_000u64}, "autoSaves": {"count": 1, "bytes": 20000}},
|
||||
"diskCache": {"enabled": true, "entries": 12, "bytes": 1_500_000, "maxBytes": 1_000_000_000u64, "hits": 4, "misses": 12}})
|
||||
}
|
||||
fn request_persist(&self) -> Value {
|
||||
*self.persist_asked.lock().unwrap() = true;
|
||||
json!({"requested": true})
|
||||
}
|
||||
fn clear(&self, what: &str) -> Result<Value, String> {
|
||||
self.cleared.lock().unwrap().push(what.into());
|
||||
Ok(json!({"cleared": what, "entries": 1, "bytes": 1}))
|
||||
}
|
||||
}
|
||||
|
||||
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
if let Outcome::Done(v) = control::handle(h.state_mut(), &ctx, &req) {
|
||||
assert!(v["ok"] != false, "{method}: {v}");
|
||||
}
|
||||
// kittest doesn't call `raw_input_hook`: feed the synthetic click in.
|
||||
for e in h.state_mut().take_synthetic_input() {
|
||||
h.event(e);
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_disk_page_shows_the_browser_storage_manager() {
|
||||
let mut s = Session::default();
|
||||
let host = Arc::new(Storage::default());
|
||||
s.storage = Some(host.clone());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(2);
|
||||
effectcraft_ui_egui::panels::settings::open(h.state_mut(), "disk");
|
||||
h.run_steps(3);
|
||||
for id in [
|
||||
"settings.storage.backend",
|
||||
"settings.storage.usage",
|
||||
"settings.storage.diskCache",
|
||||
"settings.storage.persist",
|
||||
"settings.storage.clear.diskCache",
|
||||
"settings.storage.clear.media",
|
||||
] {
|
||||
assert!(h.state().auto.find(id).is_some(), "{id} is registered");
|
||||
}
|
||||
let usage = h.state().auto.find("settings.storage.usage").unwrap().label.clone();
|
||||
assert!(usage.contains("52.0 MB of 2.0 GB"), "{usage}");
|
||||
send(&mut h, "ui.click", json!({"id": "settings.storage.clear.diskCache"}));
|
||||
send(&mut h, "ui.click", json!({"id": "settings.storage.persist"}));
|
||||
assert_eq!(*host.cleared.lock().unwrap(), vec!["diskCache".to_string()]);
|
||||
assert!(*host.persist_asked.lock().unwrap());
|
||||
// Without a host (the desktop) the section is not there.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
|
||||
h.run_steps(2);
|
||||
effectcraft_ui_egui::panels::settings::open(h.state_mut(), "disk");
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("settings.storage.usage").is_none());
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ impl RemoteFrames for FakeWorker {
|
|||
assert!(server.handle(rt(&m)).is_empty());
|
||||
}
|
||||
*next += 1;
|
||||
let out = server.handle(rt(&FrameMsg::Render { id: *next, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts) }));
|
||||
let out = server.handle(rt(&FrameMsg::Render { id: *next, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts), disk: None }));
|
||||
match out.into_iter().next() {
|
||||
Some((FrameReply::Frame { width, height, ms, .. }, Some(rgba))) => pending.push((done, RemoteFrame { width, height, rgba, ms })),
|
||||
other => done(Err(format!("{:?}", other.map(|o| o.0)))),
|
||||
|
|
|
|||
|
|
@ -127,7 +127,17 @@ preview frame are written (LZ4, atomic rename, checksummed) under 128-bit conten
|
|||
comp's content hash (the comp, what it uses, footage file size and time) plus frame, scale and
|
||||
view for frames; the layer key salted with the footage fingerprint for layers — so entries
|
||||
survive restarts and never go stale. LRU eviction keeps it under the size limit; Edit ▸ Purge
|
||||
empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue.
|
||||
empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue. In the
|
||||
browser the frames live in the Origin Private File System: frame workers write them, the page
|
||||
keeps the index (`DiskIndex`) and reads hits ([web.md](web.md#disk-cache)).
|
||||
|
||||
**Deferred readbacks** (`effectcraft-gpu` `deferred`): where a readback can't be waited for (WebGPU
|
||||
in a browser worker) a frame renders in passes. `Accelerator::frame_begin` / `pass_begin` /
|
||||
`pass_missed` / `miss_gate`: a readback not there yet starts asynchronously and the step returns a
|
||||
placeholder; the pass is missed, the layer cache's gate drops inserts until the pass ends, and the
|
||||
caller (`remote::FrameServer::resume`) renders again once the device delivered. Readbacks are keyed
|
||||
by content (a chain's input pixels and parameters; the frame's comp, time and options), so later
|
||||
passes are served from them; a pass without a miss is the frame.
|
||||
|
||||
**Time effects** (Echo, Posterize Time, Timewarp…) read the layer at other times through
|
||||
`EffectHost::self_at`: the renderer renders the layer's source and masks (plus, optionally, the
|
||||
|
|
@ -190,21 +200,35 @@ runs render on the GPU end to end (`gpu::adv3d`, `Renderer::prepare_adv_run` /
|
|||
then compute kernels (`adv3d.wgsl`) resolve the 2×2 supersampling, average the sub-samples
|
||||
(nearest depth), apply the depth-based iris depth of field with the Classic 3D bokeh spans
|
||||
(`three_d::bokeh::kernel_spans`, highlight boost, progressive blur levels) and composite over the
|
||||
GPU canvas; only the depth of field reads back its 8-byte radius range. Advanced 3D layers with
|
||||
blend modes, track mattes or Preserve Transparency and environment backgrounds take the CPU's
|
||||
2D compositing path (their scenes still render through `render_3d`). Wireframe outlines draw on
|
||||
the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU
|
||||
effects (`effects::GPU_EFFECTS`, 166 of them: blurs, colour correction, keying incl. Key Light,
|
||||
mattes, channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC
|
||||
Page Turn), Cartoon, bevels, shapes, transitions, generators, noise, grain and time; see
|
||||
GPU canvas; only the depth of field reads back its 8-byte radius range. Runs whose layers need
|
||||
the 2D compositing path (blend modes, track mattes, Preserve Transparency) come to the GPU as a
|
||||
`three_d::adv::SplitRun` (`Renderer::split_adv_run`, `draw_run`'s steps as data): the plain
|
||||
layers render as one scene over the canvas, then each special layer, farthest first, renders on
|
||||
its own, is hidden where the main scene is nearer (`occlude`), and goes through the 2D matte /
|
||||
Preserve Transparency / blend kernels (a 3D track matte renders solo through the camera).
|
||||
Environment Light Background layers draw their sky in a kernel (`adv_sky`, from
|
||||
`Renderer::sky_draw`) before the rest of the run, in Classic and Advanced 3D comps. Wireframe
|
||||
outlines draw on the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU effects
|
||||
(`effects::GPU_EFFECTS`, 196 of them: blurs, colour correction, keying incl. Key Light, mattes,
|
||||
channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC Page
|
||||
Turn, CC Bender, CC Blobbylize), Cartoon, bevels, shapes, the transitions (CC light family, CC
|
||||
transitions, Block Dissolve), perspective (Radial Shadow, CC Cylinder / Sphere / Spotlight /
|
||||
Environment, 3D Glasses), generators, noise, grain, text (Numbers, Timecode) and time; see
|
||||
[effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes)
|
||||
as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU
|
||||
effects run as one chain with one upload and one readback. Statistics that need the whole frame
|
||||
(Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's
|
||||
noise level) are measured on the CPU from one readback and applied on the GPU; effects reading
|
||||
other frames (Echo, Posterize Time) upload the frames the host renders. Settings a kernel cannot
|
||||
match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's Fisheye and Twist past
|
||||
50 % or with a distortion, whose Newton inverse wanders chaotically near the crease). Tests render
|
||||
other frames (Echo, Posterize Time, Time Difference, Time Displacement, CC Force Motion Blur, CC
|
||||
Wide Time, Pixel Motion Blur, Timewarp) upload the frames the host renders (`time_frames`) and
|
||||
combine them on the GPU; Time Displacement's per-pixel times and Timewarp's motion vectors
|
||||
(`time_fx::timewarp_plan`) are computed on the CPU. Numbers and Timecode rasterise their glyph
|
||||
coverage on the CPU (`textfx::text_layer`) and composite it on the GPU. Other layers an effect
|
||||
reads (gradients, reveals, environments) are fitted on the CPU (`util::fit_layer`) and uploaded.
|
||||
Settings a kernel cannot match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's
|
||||
Fisheye and Twist past 50 % or with a distortion, whose Newton inverse wanders chaotically near
|
||||
the crease, and Timewarp with a Matte Layer); Card Wipe (the 3D card renderer of Card Dance and
|
||||
Shatter) stays on the CPU. Tests render
|
||||
scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an
|
||||
adapter. Working textures (premultiplied RGBA f32) come from a pool: a released texture is reused
|
||||
once every encoder that could still read it has been submitted, transparent inputs share one
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 166 effects also run on the GPU compositor with identical results.
|
||||
- **GPU**: 196 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 306 implemented in full, 0 partial (what is missing is listed).
|
||||
|
||||
|
|
@ -133,8 +133,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Bezier Warp | `ec.distort.bezierwarp` | 13 | GPU | 32 | Implemented |
|
||||
| Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented |
|
||||
| CC Bend It | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented |
|
||||
| CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented |
|
||||
| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented |
|
||||
| CC Bender | `ec.distort.ccbender` | 5 | GPU | 32 | Implemented |
|
||||
| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | GPU | 32 | Implemented |
|
||||
| CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented |
|
||||
| CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented |
|
||||
| CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented |
|
||||
|
|
@ -194,9 +194,9 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented |
|
||||
| Beam | `ec.generate.beam` | 11 | | 32 | Implemented |
|
||||
| CC Glue Gun | `ec.generate.ccgluegun` | 7 | | 32 | Implemented |
|
||||
| CC Light Burst 2.5 | `ec.generate.cclightburst` | 6 | | 32 | Implemented |
|
||||
| CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented |
|
||||
| CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented |
|
||||
| CC Light Burst 2.5 | `ec.generate.cclightburst` | 6 | GPU | 32 | Implemented |
|
||||
| CC Light Rays | `ec.generate.cclightrays` | 8 | GPU | 32 | Implemented |
|
||||
| CC Light Sweep | `ec.generate.cclightsweep` | 9 | GPU | 32 | Implemented |
|
||||
| CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented |
|
||||
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented |
|
||||
| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented |
|
||||
|
|
@ -303,15 +303,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| 3D Camera Tracker | `ec.perspective.cameratracker` | 12 | | 32 | Implemented |
|
||||
| 3D Glasses | `ec.perspective.3dglasses` | 8 | | 32 | Implemented |
|
||||
| 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented |
|
||||
| Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented |
|
||||
| Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented |
|
||||
| CC Cylinder | `ec.perspective.cccylinder` | 12 | | 32 | Implemented |
|
||||
| CC Environment | `ec.perspective.ccenvironment` | 4 | | 32 | Implemented |
|
||||
| CC Sphere | `ec.perspective.ccsphere` | 14 | | 32 | Implemented |
|
||||
| CC Spotlight | `ec.perspective.ccspotlight` | 8 | | 32 | Implemented |
|
||||
| CC Cylinder | `ec.perspective.cccylinder` | 12 | GPU | 32 | Implemented |
|
||||
| CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented |
|
||||
| CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented |
|
||||
| CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented |
|
||||
| Drop Shadow | `ec.perspective.dropshadow` | 6 | GPU | 32 | Implemented |
|
||||
| Radial Shadow | `ec.perspective.radialshadow` | 9 | | 32 | Implemented |
|
||||
| Radial Shadow | `ec.perspective.radialshadow` | 9 | GPU | 32 | Implemented |
|
||||
|
||||
## Simulation
|
||||
|
||||
|
|
@ -370,37 +370,37 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| Numbers | `ec.text.numbers` | 14 | | 32 | Implemented |
|
||||
| Timecode | `ec.text.timecode` | 12 | | 32 | Implemented |
|
||||
| Numbers | `ec.text.numbers` | 14 | GPU | 32 | Implemented |
|
||||
| Timecode | `ec.text.timecode` | 12 | GPU | 32 | Implemented |
|
||||
|
||||
## Time
|
||||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | | 32 | Implemented |
|
||||
| CC Wide Time | `ec.time.ccwidetime` | 3 | | 32 | Implemented |
|
||||
| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented |
|
||||
| CC Wide Time | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented |
|
||||
| Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented |
|
||||
| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | | 32 | Implemented |
|
||||
| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | GPU | 32 | Implemented |
|
||||
| Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented |
|
||||
| Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented |
|
||||
| Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented |
|
||||
| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented |
|
||||
| Time Difference | `ec.time.timedifference` | 5 | GPU | 32 | Implemented |
|
||||
| Time Displacement | `ec.time.timedisplacement` | 4 | GPU | 32 | Implemented |
|
||||
| Timewarp | `ec.time.timewarp` | 28 | GPU | 32 | Implemented |
|
||||
|
||||
## Transition
|
||||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| Block Dissolve | `ec.transition.blockdissolve` | 5 | | 32 | Implemented |
|
||||
| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | | 32 | Implemented |
|
||||
| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | | 32 | Implemented |
|
||||
| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | | 32 | Implemented |
|
||||
| CC Jaws | `ec.transition.ccjaws` | 6 | | 32 | Implemented |
|
||||
| CC Light Wipe | `ec.transition.cclightwipe` | 8 | | 32 | Implemented |
|
||||
| CC Line Sweep | `ec.transition.cclinesweep` | 5 | | 32 | Implemented |
|
||||
| CC Radial ScaleWipe | `ec.transition.ccradialscalewipe` | 3 | | 32 | Implemented |
|
||||
| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented |
|
||||
| CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented |
|
||||
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented |
|
||||
| Block Dissolve | `ec.transition.blockdissolve` | 5 | GPU | 32 | Implemented |
|
||||
| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | GPU | 32 | Implemented |
|
||||
| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | GPU | 32 | Implemented |
|
||||
| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | GPU | 32 | Implemented |
|
||||
| CC Jaws | `ec.transition.ccjaws` | 6 | GPU | 32 | Implemented |
|
||||
| CC Light Wipe | `ec.transition.cclightwipe` | 8 | GPU | 32 | Implemented |
|
||||
| CC Line Sweep | `ec.transition.cclinesweep` | 5 | GPU | 32 | Implemented |
|
||||
| CC Radial ScaleWipe | `ec.transition.ccradialscalewipe` | 3 | GPU | 32 | Implemented |
|
||||
| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | GPU | 32 | Implemented |
|
||||
| CC Twister | `ec.transition.cctwister` | 5 | GPU | 32 | Implemented |
|
||||
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | GPU | 32 | Implemented |
|
||||
| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented |
|
||||
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
|
||||
| Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented |
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much
|
|||
| Unweighted | ≈ 97% (half-credit) / 99% (fractions) |
|
||||
| P0 / P1 / P2 | ≈ 99% / 96% / 90% (half-credit); 99.8% / 99.2% / 97.5% (fractions) |
|
||||
| Features done / partial / missing | 87 / 5 / 0 of 92 |
|
||||
| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 166 run on the GPU |
|
||||
| **Effects** | **306** effects (all After Effects 2026 effects), every one implemented in full ([effects.md](effects.md)); 196 run on the GPU |
|
||||
| Disabled menu entries left | 1, on purpose: Import ▸ Vanishing Point (.vpe), whose format has no public specification |
|
||||
| Remaining work | ≈ 9–11 agent-hours at the pace measured so far (≈ 29 on the conservative audit scale); ≈ 6–7 without the learned-model items |
|
||||
| **Wall-clock estimate** | **≈ 2–2.5 hours** with five agents in parallel; ≈ 1.5 hours for 100% of P0 + P1 |
|
||||
|
|
@ -20,7 +20,7 @@ Partial features:
|
|||
| id | Tier | Done | What is missing |
|
||||
|---|---|---|---|
|
||||
| EFF-5 GPU effects | P1 | 0.85 | GPU kernels for the CC light / transition families, Numbers and Timecode, 3D Channel, the VR family, OCIO / LUT, Time effects and the simulations; Advanced 3D runs with blend modes, track mattes or environment backgrounds composite on the CPU |
|
||||
| WEB-1 Web app | P0 | 0.9 | GPU effects in the browser (needs WebGPU in the render workers or async readback), a disk cache in browser storage (OPFS), a storage manager, threads inside one engine instance |
|
||||
| WEB-1 Web app | P0 | 0.97 | GPU effects in Render Queue / analysis job workers (they render on their CPU); layer buffers in the browser's disk cache (frames only); threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`). M13.24 added GPU effects and the GPU compositor in the frame workers (their own WebGPU devices, deferred readbacks: frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.*`) and fixed a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) |
|
||||
| UI-7 Home screen | P1 | 0.95 | A "start from a template" gallery |
|
||||
| MSK-4 Roto Brush | P2 | 0.85 | Segmentation is classical (graph cut + optical flow), not a learned model |
|
||||
| TRK-3 Face tracking | P2 | 0.9 | Classical fitter: weak on profile and occluded faces; Rolling Shutter Ripple is approximated |
|
||||
|
|
@ -35,7 +35,8 @@ effects, a texture pool and per-comp CPU/GPU choice; M13.10 render workers in th
|
|||
visual editors for curves and palettes; M13.11 Advanced 3D on the GPU and Essential Graphics
|
||||
mirrors; M13.14–M13.20 puppet pin editing and rigging; M13.15 end-to-end tests through MCP and
|
||||
the CLI; M13.21 lazy project open, virtualised panels, background auto-save and an audit that
|
||||
every command is reachable over MCP, the control channel and the CLI.
|
||||
every command is reachable over MCP, the control channel and the CLI; M13.24 GPU effects in the
|
||||
browser's frame workers, the browser disk cache (OPFS) and a storage manager.
|
||||
|
||||
Out of clean-room scope (no public specification or no permissively licensed data): `.jsxbin`,
|
||||
Vanishing Point `.vpe`, the predefined CJK CMaps in PDF import, Kodak film emulations. Adobe
|
||||
|
|
@ -153,13 +154,13 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates (CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) |
|
||||
| Web | 97% | 0.2 | GPU effects in Render Queue / analysis workers (job workers render on their CPU), layer buffers in the browser's disk cache (frames only), no shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
|
||||
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (196 run on the GPU since M13.22, EFF-5: the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; simulations, 3D channel, VR, audio-driven, OCIO and Card Wipe still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Preview | 77% | 2.8 | GPU effects for simulations, 3D channel, VR, OCIO and Card Wipe (Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |
|
||||
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
|
||||
|
||||
|
|
@ -223,6 +224,43 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8).
|
|||
as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the
|
||||
parent's renderer); text and shape strokes extrude as bevelled meshes in paint order.
|
||||
|
||||
## Update: M13.22 GPU effects, part A
|
||||
|
||||
- **GPU ports (EFF-5)**: 30 more effects run on the GPU (196 GPU effects), each matching the
|
||||
CPU oracle within 1/255 (8 bpc) / 1e-3 (32 bpc) on every tested pixel, directly on a buffer
|
||||
(full and half resolution, as adjustment) and composited at 8 and 32 bpc:
|
||||
- the CC light family: CC Light Rays, CC Light Burst 2.5, CC Light Sweep, CC Light Wipe
|
||||
(`gpu::fx_light`);
|
||||
- transitions and perspective (`gpu::fx_transition`): Block Dissolve (block indices computed
|
||||
on the CPU per column / row sub-sample, so the hash sees the CPU's integers), CC Glass Wipe,
|
||||
CC Grid Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, CC Radial ScaleWipe, CC Scale Wipe, CC
|
||||
Twister, CC WarpoMatic, Radial Shadow, CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC
|
||||
Spotlight, CC Environment, 3D Glasses; other layers they read (gradient, reveal, backside,
|
||||
environment, stereo views) are fitted on the CPU and uploaded;
|
||||
- Numbers and Timecode (`gpu::fx_text`): the glyph coverage (fill and stroke ring) is
|
||||
rasterised on the CPU with the effect's own stroke font and composited on the GPU with the
|
||||
box, fill and stroke;
|
||||
- time (`gpu::fx_time`): Time Difference, Time Displacement, CC Force Motion Blur, CC Wide
|
||||
Time, Pixel Motion Blur and Timewarp fetch their frames through the host as Echo does and
|
||||
combine them on the GPU (weighted sums one frame per pass, in the CPU's order); Time
|
||||
Displacement's per-pixel times and Timewarp's motion vectors (block matching and smoothing)
|
||||
are computed on the CPU, Timewarp's Whole Frames / Frame Mix / Pixel Motion frame building
|
||||
and shutter average on the GPU.
|
||||
- **Fallbacks and deviations**: Timewarp with a Matte Layer renders on the CPU
|
||||
(`gpu_supported`); Card Wipe (the 3D card renderer shared with Card Dance and Shatter) and the
|
||||
3D Camera Tracker stay on the CPU. CC Environment now fades nearly transparent environment
|
||||
texels to black continuously (colour / max(alpha, 1e-3), on both paths) instead of a hard
|
||||
1e-6 un-premultiply cut-off, which turned the filtered map's float noise into colour. CC
|
||||
Sphere / CC Environment keep their longitude on the side of the ±180° seam the ray's sign
|
||||
puts it (a GPU's approximate `atan2` can land across it).
|
||||
- **Advanced 3D compositing on the GPU (3D-3, PRV-3)**: runs with blend modes, track mattes
|
||||
(2D, or 3D mattes drawn through the camera) or Preserve Transparency no longer read back:
|
||||
`Renderer::split_adv_run` hands `draw_run`'s split path to the GPU (main scene, then each
|
||||
special layer far to near, hidden behind the nearer main scene and composited through the 2D
|
||||
kernels), and Environment Light Background skies draw in a kernel in Classic and Advanced 3D
|
||||
comps. The GPU walk matches the CPU compositor on the same rasters exactly at 8 and 32 bpc,
|
||||
with only the frame's own readback.
|
||||
|
||||
## Update: M13.13 GPU performance and the remaining GPU ports
|
||||
|
||||
- **Small comps on the GPU (PRV-3)**: the GPU frame allocated (and zero-initialised) several
|
||||
|
|
|
|||
|
|
@ -112,7 +112,10 @@ Commands (CLI, MCP, control channel):
|
|||
- `audio.previewSampleRate`: the rate previews are mixed at (and the device is opened at, when
|
||||
it supports it)
|
||||
- `disk.diskCacheEnabled`, `disk.diskCacheMaxGb`, `disk.diskCacheFolder`: the persistent disk
|
||||
cache of processed layers
|
||||
cache of processed layers and frames (in the browser: frames in the Origin Private File System,
|
||||
at most half the origin's quota; the folder setting does not apply there). The Disk page also
|
||||
shows **Browser Storage** in the web app: usage and quota, persistent storage, Clear buttons
|
||||
(`storage.info` / `storage.persist` / `storage.clear`, see [web.md](web.md))
|
||||
- `disk.mediaCacheFolder`: audio waveform summaries kept between sessions (`Peaks/`)
|
||||
- `disk.conformedMediaFolder`: decoded (conformed) footage audio, written once per file and
|
||||
sample rate and read back instead of decoding again
|
||||
|
|
|
|||
162
docs/web.md
162
docs/web.md
|
|
@ -43,13 +43,14 @@ URL flags: `?empty` (a blank project; the session is not restored or recorded),
|
|||
project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` /
|
||||
`?storage=memory` (force a storage backend), `?noworkers` (render, analyse and draw viewer
|
||||
frames on the page's thread), `?frameworkers=N` (frame workers, default 2; 0 = viewer frames on
|
||||
the page's thread), `?nosw` (don't register the service worker).
|
||||
the page's thread), `?nogpuworkers` (frame workers render on their CPU, without a WebGPU device of
|
||||
their own), `?nosw` (don't register the service worker).
|
||||
|
||||
## How the desktop pieces map to the browser
|
||||
|
||||
| Desktop | Web |
|
||||
|---|---|
|
||||
| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below): `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames the GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) |
|
||||
| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below), each with its own WebGPU device: `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames without effects that the page's GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) |
|
||||
| Render Queue on a background thread | a Web Worker running a second engine instance (below); progress streams back, the page never waits |
|
||||
| tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step |
|
||||
| `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial |
|
||||
|
|
@ -64,7 +65,9 @@ the page's thread), `?nosw` (don't register the service worker).
|
|||
| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there |
|
||||
| TCP control channel / MCP | `window.effectcraft` (below) |
|
||||
| cpal audio output | Web Audio (below) |
|
||||
| GPU compositor on the desktop's wgpu device | the same compositor on eframe's WebGPU device (below) |
|
||||
| GPU compositor on the desktop's wgpu device | the same compositor on eframe's WebGPU device, and GPU effects on each frame worker's own WebGPU device (below) |
|
||||
| disk cache folder (Settings ▸ Disk ▸ Disk Cache) | viewer frames in the Origin Private File System, written by the frame workers (below) |
|
||||
| (the file system's own tools) | the storage manager: Settings ▸ Disk ▸ Browser Storage and `storage.*` (below) |
|
||||
|
||||
### Persistence
|
||||
|
||||
|
|
@ -144,17 +147,26 @@ of the project:
|
|||
before prefetch); a request for a revision the worker does not hold is refused, a worker that
|
||||
lost its replica asks for the whole project again (`FrameReply::Resync`).
|
||||
|
||||
Frames the WebGPU compositor can finish alone still render on the page (they stay on the GPU, no
|
||||
readback); everything else (CPU-only effects, 3D runs, the Software Only renderer, WebGL2) goes to
|
||||
the workers. If no worker is usable, frames render on the page's thread as before.
|
||||
`effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered, failed, lastMs,
|
||||
syncs, syncBytes}`.
|
||||
Frames without effects that the WebGPU compositor can finish alone still render on the page
|
||||
(they stay on the GPU, no readback); everything else (frames with effects, 3D runs, the Software
|
||||
Only renderer, WebGL2) goes to the workers. If no worker is usable, frames render on the page's
|
||||
thread as before. `effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered,
|
||||
failed, lastMs, syncs, syncBytes, gpu, gpuFrames, lastPasses, diskHits}` (`gpu`: each worker's
|
||||
adapter, `{cpu: why}` without one, `null` until it reported).
|
||||
|
||||
**Why workers and not wasm threads:** shared-memory threads (SharedArrayBuffer + atomics, a
|
||||
`wasm-bindgen-rayon`-style pool) need the standard library rebuilt with atomics (`build-std`,
|
||||
nightly Rust); the repository builds on stable, so each worker runs its own engine instance with
|
||||
its own memory. The page is still cross-origin isolated (`--serve` and the service worker send
|
||||
COOP / COEP), so a threaded build can be added later without changing the deployment.
|
||||
**Threading design: one engine instance per worker (decided, M13.24).** Shared-memory threads
|
||||
inside one instance (SharedArrayBuffer + atomics, a `wasm-bindgen-rayon`-style pool) need the
|
||||
standard library rebuilt with atomics (`-Z build-std`, nightly Rust) and every crate's statics
|
||||
made thread-safe for the browser's rules (no blocking on the page's thread). The repository
|
||||
builds on stable Rust, so concurrency comes from workers that each run their own engine instance
|
||||
with their own memory: job workers (renders, analyses), frame workers (viewer frames, each with
|
||||
its own GPU device) and the page. What crosses between them is explicit: project diffs, footage
|
||||
bytes, frames, Roto Brush segmentations, cache keys. The cost is memory per worker (a project
|
||||
replica, the footage it reads, a layer cache) and serial loops inside one frame (rayon's pool
|
||||
falls back to the calling thread). The page is still cross-origin isolated (`--serve` and the
|
||||
service worker send COOP / COEP), so a threaded build (`build-std` with `+atomics,+bulk-memory`,
|
||||
`wasm-bindgen-rayon`) could be added later without changing the deployment; it would parallelise
|
||||
the loops inside a frame, not replace the workers.
|
||||
|
||||
### Media Browser
|
||||
|
||||
|
|
@ -189,13 +201,94 @@ key press.
|
|||
|
||||
eframe starts on WebGPU where the browser has it (WebGL2 otherwise), and the GPU compositor
|
||||
(`effectcraft-gpu`) runs on the same device: viewer frames are composited by compute shaders and
|
||||
drawn straight from their texture, with no readback. Steps that need the CPU mid-render (3D runs,
|
||||
adjustment layers, CPU-only effects) fall back to the CPU renderer for that frame, because the
|
||||
page's thread can't wait for a GPU readback. The Info panel's pixel readout and the eyedroppers
|
||||
read GPU frames back asynchronously (`Gpu::read_display_async`: the pixels arrive a frame
|
||||
later). On WebGL2, or without a usable adapter, everything renders on the CPU.
|
||||
drawn straight from their texture, with no readback. The Info panel's pixel readout and the
|
||||
eyedroppers read GPU frames back asynchronously (`Gpu::read_display_async`: the pixels arrive a
|
||||
frame later). On WebGL2, or without a usable adapter, everything renders on the CPU.
|
||||
`effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`.
|
||||
|
||||
**GPU effects run in the frame workers.** A browser never lets JavaScript (or wasm) wait for a GPU
|
||||
readback, not even in a worker: a buffer maps only once the thread returns to its event loop.
|
||||
The CPU renderer, which runs GPU effect chains and reads their results back mid-frame, therefore
|
||||
can't use the page's device. Instead each frame worker opens its own WebGPU device
|
||||
(`navigator.gpu` in dedicated workers: Chromium, Safari Technology Preview; `Gpu::request_deferred`)
|
||||
and renders with **deferred readbacks** (`crates/gpu/src/deferred.rs`):
|
||||
|
||||
1. Every readback the renderer needs is keyed by what determines its pixels: a GPU effect chain by
|
||||
its input buffer's pixels and the chain's parameters, the top-level frame by comp, time and
|
||||
render options.
|
||||
2. A key seen for the first time records the GPU work, starts an asynchronous copy (`map_async`)
|
||||
and *misses*: the step returns a cheap placeholder (its input), and the pass is marked missed.
|
||||
From the first miss to the end of the pass, the layer cache drops inserts
|
||||
(`LayerCache::set_gate`), so no placeholder-derived buffer is kept.
|
||||
3. The worker awaits the device (`Gpu::settled`) and renders the frame again
|
||||
(`FrameServer::resume`); keys whose bytes arrived are served from them. A pass without a miss
|
||||
is the frame. Chains that depend on other chains (a precomp with effects inside, effects that
|
||||
read other layers) take one more pass per level; independent chains resolve together. A chain
|
||||
whose kernels read data that missed while they ran (through the effect host) is not started,
|
||||
so a result is only ever computed from genuine inputs. After 24 passes a frame renders on the
|
||||
worker's CPU.
|
||||
|
||||
The whole frame renders on the GPU (the compositor walk with GPU effects, then one readback) when
|
||||
it can; steps that would need a mid-walk readback (an Advanced 3D run the kernels decline, CPU-only
|
||||
effects on an adjustment layer) make the frame composite on the CPU instead, with its GPU effect
|
||||
chains still on the GPU. Backend **Auto** works in the workers: it picks CPU or GPU per comp from
|
||||
the frames' total times across their passes (`AutoPick`), as on the desktop. Without WebGPU in
|
||||
workers (Firefox, older Safari) or with `?nogpuworkers`, the workers render on their CPU, as
|
||||
before. A frame with effects goes to a worker when the workers have a GPU, so the page never runs
|
||||
an effect on its thread. Particles and Advanced 3D scenes (which read back without a key) simulate
|
||||
and rasterise on the worker's CPU.
|
||||
|
||||
`effectcraft.workerFrameCheck({time?, scale?, backend?})` renders the active comp's frame in a
|
||||
worker (its GPU by default) and on the page's CPU and reports the differences (`{maxDiff,
|
||||
meanDiff, over4, workerMs}` in 8-bit levels): the smoke test uses it to check the GPU workers'
|
||||
pixels.
|
||||
|
||||
### Disk cache
|
||||
|
||||
Settings ▸ Disk ▸ Disk Cache works in the browser, backed by the **Origin Private File System**
|
||||
(`src/diskcache.rs`): viewer frames are kept under `effectcraft-cache/v1/frames/<32 hex>.ecc`
|
||||
between visits, in the desktop cache's format (`disk_cache::frame_entry`: 8-bit premultiplied
|
||||
RGBA, LZ4, a checksum), keyed by the same content hash (the comp and everything it uses, frame,
|
||||
scale, view, render options).
|
||||
|
||||
- A render request to a frame worker carries the frame's disk key; the worker posts the frame,
|
||||
then writes the entry with a **synchronous access handle** into a temporary file moved into
|
||||
place (`cacheWrite` in `js/host.js`; where `move()` is missing, the entry is written in place
|
||||
and its checksum catches a partial file), and reports `FrameReply::Stored`.
|
||||
- The page keeps the index (`DiskIndex`, shared with the desktop's cache): rebuilt at startup from
|
||||
the folder listing (oldest files first, stale temporaries removed), updated by the workers'
|
||||
writes and by reads. The size limit is Maximum Disk Cache Size, at most half the origin's quota;
|
||||
the least recently used entries are deleted beyond it.
|
||||
- A frame whose key is indexed is read and decoded on the page (asynchronously) instead of being
|
||||
rendered; a damaged or missing file is a miss and is forgotten. The timeline's blue cache bar
|
||||
shows the frames on disk.
|
||||
- `cache.diskStats` reports the browser's cache (`{enabled, available, loaded, entries, bytes,
|
||||
maxBytes, hits, misses, writes, evictions}`); Empty Disk Cache / `edit.purge {what: "disk"}`
|
||||
clears it. `effectcraft.info().diskCache` has the same.
|
||||
|
||||
Only frames are cached on disk in the browser: the desktop also keeps slow layer buffers, but a
|
||||
layer lookup happens in the middle of a synchronous render, where the browser's file system can't
|
||||
be read.
|
||||
|
||||
### Storage manager
|
||||
|
||||
Settings ▸ Disk ▸ **Browser Storage** (shown in the browser only) reports where the data lives
|
||||
(Origin Private File System, IndexedDB or memory), the origin's usage and quota
|
||||
(`navigator.storage.estimate()`), what the store holds (projects, imported media, auto-saves:
|
||||
count and size) and the disk cache, whether the storage is persistent (with **Request Persistent
|
||||
Storage**, `navigator.storage.persist()`), and **Clear** buttons for the disk cache, imported
|
||||
media, auto-saves and saved projects. Agents use the same through engine commands
|
||||
(`crates/engine/src/storage.rs`, the session's `StorageHost`):
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `storage.info` | `{available, backend, usage, quota, persisted, files: {projects, media, autoSaves: {count, bytes}}, diskCache: {...}}` (`{available: false}` on the desktop) |
|
||||
| `storage.persist` | ask for persistent storage; `persisted` in a later `storage.info` says whether it was granted |
|
||||
| `storage.clear {what: diskCache\|media\|projects\|autoSaves\|all}` | delete them (settings stay); `{cleared, entries, bytes}` |
|
||||
|
||||
The widgets have automation ids `settings.storage.backend`, `.usage`, `.diskCache`, `.persist`
|
||||
and `.clear.<what>`.
|
||||
|
||||
### Offline and install (PWA)
|
||||
|
||||
`manifest.webmanifest` (name, icons, standalone display) makes the app installable; `sw.js`
|
||||
|
|
@ -222,7 +315,8 @@ with the method's `result`, errors reject with the message.
|
|||
| `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` |
|
||||
| `effectcraft.removeStored(path)` | delete a stored file |
|
||||
| `effectcraft.flush()` | resolves once every change is written to browser storage |
|
||||
| `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings |
|
||||
| `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `frameWorkers`, `diskCache`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings |
|
||||
| `effectcraft.workerFrameCheck(params)` | a frame rendered in a frame worker (its GPU by default) against the page's CPU render: `{width, height, maxDiff, meanDiff, over4, workerMs}` |
|
||||
|
||||
`window.effectcraftLoad` holds `{wasmMs, readyMs}` (module fetch + compile, and until the app runs).
|
||||
|
||||
|
|
@ -251,7 +345,12 @@ node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out tar
|
|||
|
||||
It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the
|
||||
CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that
|
||||
replies when done while the page keeps running, and the Media Browser over browser storage.
|
||||
replies when done while the page keeps running, and the Media Browser over browser storage; and
|
||||
the M13.24 ones: GPU effects (Gaussian Blur, Glow) rendered on the frame workers' own WebGPU
|
||||
devices in passes while the page's event loop stays free, the worker's GPU frame matching the
|
||||
page's CPU render (`workerFrameCheck`), the storage manager (`storage.info`, `storage.persist`,
|
||||
the Settings ▸ Disk widgets), and the disk cache: frames written to the Origin Private File
|
||||
System, served from it after a reload without rendering, and cleared with `storage.clear`.
|
||||
|
||||
Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write
|
||||
coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the
|
||||
|
|
@ -261,15 +360,24 @@ propagation through an offload streaming segmentations), the frame-worker protoc
|
|||
(`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels;
|
||||
`crates/ui-egui/tests/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true`
|
||||
deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`,
|
||||
`tests_panels.rs`).
|
||||
`tests_panels.rs`). M13.24: deferred readbacks on a real device (`crates/gpu/src/tests_deferred.rs`:
|
||||
whole-frame GPU and CPU-composited frames in passes match the CPU, placeholders never reach the
|
||||
layer cache; `deferred.rs`: keys, late readbacks of an older frame dropped), the frame server's
|
||||
passes and Auto timing (`remote.rs`, a deferred stand-in accelerator), the browser's routing and
|
||||
disk cache (`crates/ui-egui/tests/ui_web_gpu_storage.rs`: a GPU frame worker renders effect frames,
|
||||
the page's GPU keeps frames without effects, frames stored under their disk keys come back after a
|
||||
"reload" without rendering, the blue cache bar sees them; the storage manager's widgets and
|
||||
commands), the disk index (`disk_cache.rs`), the store's usage by kind and clearing (`store.rs`),
|
||||
`storage.*` (`storage.rs`), and every WGSL float literal fitting f32 (browsers reject the whole
|
||||
module otherwise; `tests.rs`).
|
||||
|
||||
## Gaps
|
||||
|
||||
- No shared-memory threads: each worker is a separate engine instance (memory per worker: the
|
||||
project replica and the footage it reads).
|
||||
- GPU effects run only in the desktop GPU path: in the browser, effects run on the CPU (inside
|
||||
workers for renders) because the page's thread can't wait for mid-render readbacks.
|
||||
- No disk cache (Settings ▸ Media & Disk Cache) in the browser: the layer cache stays in memory.
|
||||
project replica and the footage it reads); see the threading design above.
|
||||
- GPU effects run in the frame workers (viewer frames, RAM preview). Render Queue renders and
|
||||
analyses in job workers still run on their CPU (their loops are synchronous); particles and
|
||||
Advanced 3D scenes in frame workers too. Browsers without WebGPU in workers render frames on
|
||||
the workers' CPU.
|
||||
- The browser's disk cache holds frames, not layer buffers.
|
||||
- Cancelling an analysis drops its partial result (the worker is terminated).
|
||||
- Stored media count against the origin's storage quota; `effectcraft.listStored()` shows usage,
|
||||
`removeStored` frees it (there is no storage manager in the UI yet).
|
||||
|
|
|
|||
Loading…
Reference in New Issue