Files
bflatastic 1e88b9041f Smooth 8K strokes, decal handles, displacement, refraction, release readiness
- Strokes pre-blend their commit while painting and recompose committed tiles at low
  priority: the 8K stroke-end frame drops from 30-42 ms to 12-19 ms
- Texture set resize runs in the background (8K: 3 s to 0.5 s, UV padding map from island
  borders only)
- Decal move/scale/rotate handles; FBX and USD high-poly textures for the color map;
  viewport displacement from the height channel; screen-space refraction of the scene
- CI pinned to Rust 1.96.0; FreeBSD VM sized for hosted runners; release builds embed
  craft-fonts; file associations for FBX, COLLADA, USD and Alembic on every platform
- README, release checklist, NOTICE and attribution brought up to date

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:25:10 +07:00

353 lines
16 KiB
Rust

//! Compositing benchmarks at 4K and 8K (run with `--ignored --nocapture` in release).
use std::time::{Duration, Instant};
use rayon::prelude::*;
use serde_json::{Value, json};
use texturecraft_engine::compose::{DisplayKind, tile_bytes};
use texturecraft_engine::geom::glam::Vec2;
use texturecraft_engine::geom::primitives;
use texturecraft_engine::{Session, execute};
/// A ~10 layer stack: procedural fills, generators, a blurred mask, a folder and painted layers.
fn stack(res: u32) -> Session {
let mut s = Session::new_project("perf", primitives::sphere(1.0, 64, 32), "builtin:sphere", None, res).unwrap();
s.viewport = Vec2::new(800.0, 800.0);
let run = |s: &mut Session, id: &str, p: Value| execute(s, id, &p).unwrap();
run(&mut s, "layer.add.fill", json!({"color": [0.6, 0.5, 0.4], "roughness": 0.6}));
run(&mut s, "layer.fill.set", json!({"channel": "baseColor", "procedural": "clouds", "low": [0.2, 0.2, 0.2], "high": [0.8, 0.7, 0.6]}));
run(&mut s, "layer.fill.set", json!({"channel": "height", "procedural": "cells", "low": [-0.2, -0.2, -0.2], "high": [0.2, 0.2, 0.2]}));
run(&mut s, "layer.add.fill", json!({"color": [0.1, 0.3, 0.6]}));
run(&mut s, "layer.fill.set", json!({"channel": "roughness", "procedural": "grunge"}));
run(&mut s, "mask.add.black", Value::Null);
run(&mut s, "effect.add.generator", json!({"generator": "position", "level": 0.5}));
run(&mut s, "effect.add.filter", json!({"filter": "blur", "radius": 8}));
run(&mut s, "layer.add.fill", json!({"roughness": 0.3}));
run(&mut s, "layer.fill.set", json!({"channel": "baseColor", "procedural": "wood"}));
run(&mut s, "layer.fill.projection", json!({"projection": "triplanar", "scale": 2}));
run(&mut s, "mask.add.black", Value::Null);
run(&mut s, "effect.add.fill", json!({"procedural": "scratches", "scale": 6}));
run(&mut s, "layer.add.fill", json!({"color": [0.2, 0.2, 0.2], "metallic": 1.0}));
run(&mut s, "mask.add.black", Value::Null);
run(&mut s, "effect.add.generator", json!({"generator": "dirt"}));
run(&mut s, "layer.add.fill", json!({"roughness": 0.8}));
run(&mut s, "layer.fill.set", json!({"channel": "roughness", "procedural": "marble"}));
run(&mut s, "layer.add.group", Value::Null);
run(&mut s, "layer.add.fill", json!({"color": [0.9, 0.9, 0.2]}));
run(&mut s, "mask.add.black", Value::Null);
run(&mut s, "effect.add.generator", json!({"generator": "direction"}));
run(&mut s, "effect.add.levels", json!({"inLow": 0.3, "inHigh": 0.7}));
let top = s.doc.project.texture_sets[0].stack.last().copied().unwrap();
run(&mut s, "layer.select", json!({"layer": top.0}));
run(&mut s, "layer.add.paint", Value::Null);
run(&mut s, "tool.brush", json!({"size": 6, "hardness": 80}));
run(&mut s, "tool.stroke", json!({"points": [[200, 300], [600, 500]]}));
run(&mut s, "layer.add.paint", Value::Null);
s.update();
s
}
fn upload_bytes(s: &mut Session) -> usize {
let tiles = s.take_uploads(0);
let baked = s.baked(0).cloned().unwrap_or_default();
let comp = &s.caches[0].composite;
// As the UI does it (in parallel).
tiles.par_iter().flat_map_iter(|&(tx, ty)| DisplayKind::ALL.map(|k| tile_bytes(comp, &baked, k, tx, ty).len())).sum()
}
fn mb(b: usize) -> f64 {
b as f64 / (1024.0 * 1024.0)
}
fn bench(res: u32) {
let t = Instant::now();
let mut s = stack(res);
println!("[{res}²] setup {:.2?} ({} layers, {} covered tiles)", t.elapsed(), s.doc.project.walk(0).len(), s.caches[0].covered_tiles.len());
s.caches[0].invalidate_all();
let t = Instant::now();
s.update();
println!("[{res}²] full recomposite (Session::update) {:.2?}", t.elapsed());
let t = Instant::now();
let up = upload_bytes(&mut s);
println!("[{res}²] full display tile conversion {:.2?} ({:.0} MB)", t.elapsed(), mb(up));
// The UI's budgeted update: a full recomposite spread over frames.
s.caches[0].invalidate_all();
let clock = Instant::now();
let ms = || clock.elapsed().as_secs_f64() * 1000.0;
let (mut frames, mut worst) = (0, Duration::ZERO);
while s.pending_tiles() > 0 {
let t = Instant::now();
s.update_for(30.0, ms);
worst = worst.max(t.elapsed());
frames += 1;
}
println!("[{res}²] budgeted full recomposite: {frames} frames, worst frame {worst:.2?}, total {:.2?}", clock.elapsed());
// Strokes on the top paint layer, twice over the same path: per-frame dirty-tile composite
// + display conversion, then the commit. Brush sizes are relative to the mesh: the largest
// one covers a third of the texture per stamp.
for (size, brush) in [(0.5, "largest brush"), (0.02, "default brush")] {
s.tool.brush.size = size;
for pass in ["1st stroke", "2nd stroke"] {
let (mut comp, mut conv, mut worst, mut tiles) = (Duration::ZERO, Duration::ZERO, Duration::ZERO, 0usize);
s.begin_stroke(Vec2::new(250.0, 400.0), s.viewport, 1.0).unwrap();
let frames = 40;
for i in 0..frames {
let p = Vec2::new(250.0 + i as f32 * 8.0, 400.0 + (i as f32 * 0.3).sin() * 40.0);
s.continue_stroke(p, 1.0);
tiles += s.caches[0].dirty.len();
let t = Instant::now();
s.update();
let c = t.elapsed();
let t = Instant::now();
upload_bytes(&mut s);
let u = t.elapsed();
comp += c;
conv += u;
worst = worst.max(c + u);
}
s.end_stroke().unwrap();
let t = Instant::now();
s.update();
let commit = t.elapsed();
let f = frames as u32;
println!(
"[{res}²] {brush}, {pass}: frame composite {:.2?} + display {:.2?} avg, worst {worst:.2?} ({:.0} dirty tiles/frame); commit {commit:.2?}",
comp / f,
conv / f,
tiles as f64 / frames as f64
);
}
}
let m = s.memory();
println!("[{res}²] memory: layer pixels {:.1} MB, composites {:.1} MB, hot-layer cache {:.1} MB", mb(m.pixels), mb(m.composites), mb(m.cache));
}
#[test]
#[ignore]
fn bench_4k() {
bench(4096);
}
#[test]
#[ignore]
fn bench_8k() {
bench(8192);
}
/// What one interactive painting frame costs in the app, stage by stage, on the startup sample
/// project (2K material ball with a smart material folder): stamping, compositing the dirty
/// tiles, display conversion; then the commit at release. Painted twice: a new layer inside the
/// smart material folder (on top of its fills), then the bottom layer (everything above it).
#[test]
#[ignore]
fn bench_sample_painting() {
let mut s = texturecraft_engine::project_cmds::sample_session(2048, 512).unwrap();
s.viewport = Vec2::new(1000.0, 880.0);
let bottom = s.doc.project.texture_sets[0].stack[0];
execute(&mut s, "layer.add.paint", &Value::Null).unwrap();
s.update();
upload_bytes(&mut s);
for place in ["in the smart material folder", "at the bottom of the stack"] {
if place == "at the bottom of the stack" {
execute(&mut s, "layer.select", &json!({"layer": bottom.0})).unwrap();
}
for (size, brush) in [(0.02, "default brush"), (0.08, "large brush")] {
s.tool.brush.size = size;
let (mut stamp, mut comp, mut conv, mut worst, mut tiles) = (Duration::ZERO, Duration::ZERO, Duration::ZERO, Duration::ZERO, 0usize);
let t = Instant::now();
s.begin_stroke(Vec2::new(350.0, 400.0), s.viewport, 1.0).unwrap();
let begin = t.elapsed();
let frames = 60;
for i in 0..frames {
let p = Vec2::new(350.0 + i as f32 * 5.0, 400.0 + (i as f32 * 0.2).sin() * 60.0);
let t = Instant::now();
s.continue_stroke(p, 1.0);
let a = t.elapsed();
tiles += s.caches.iter().map(|c| c.dirty.len()).sum::<usize>();
let t = Instant::now();
s.update();
let b = t.elapsed();
let t = Instant::now();
upload_bytes(&mut s);
let c = t.elapsed();
stamp += a;
comp += b;
conv += c;
worst = worst.max(a + b + c);
}
let t = Instant::now();
s.end_stroke().unwrap();
let end = t.elapsed();
let committed = s.caches[0].dirty.len();
let t = Instant::now();
s.update();
let after = t.elapsed();
let f = frames as u32;
let below = &s.caches[0].below;
println!(
"[sample] {place}, {brush}: begin {begin:.2?}; per frame stamp {:.2?} + composite {:.2?} ({:.2?}/tile) + display {:.2?}, worst {worst:.2?} ({:.0} dirty tiles/frame); end_stroke {end:.2?} + update {after:.2?} ({committed} tiles); cache {} tiles, {:.1} MB, {:.0} KB/tile",
stamp / f,
comp / f,
comp / tiles.max(1) as u32,
conv / f,
tiles as f64 / frames as f64,
below.len(),
mb(below.bytes()),
below.tile_bytes().unwrap_or(0) as f64 / 1024.0
);
}
}
}
/// Per-texel overhead of the compositor on a one-paint-layer project.
#[test]
#[ignore]
fn bench_minimal_stack() {
let mut s = Session::new_project("min", primitives::sphere(1.0, 64, 32), "builtin:sphere", None, 2048).unwrap();
s.viewport = Vec2::new(800.0, 800.0);
s.update();
for label in ["empty set", "one paint layer"] {
if label == "one paint layer" {
execute(&mut s, "layer.add.paint", &Value::Null).unwrap();
execute(&mut s, "tool.stroke", &json!({"points": [[200, 300], [600, 500]]})).unwrap();
s.update();
}
s.caches[0].invalidate_all();
let n = s.caches[0].dirty.len();
let t = Instant::now();
s.update();
println!("[min] {label}: {n} tiles in {:.2?} ({:.2?}/tile)", t.elapsed(), t.elapsed() / n.max(1) as u32);
}
}
/// Full-composite cost of every built-in material on the sample ball (1K, baked), slowest first.
#[test]
#[ignore]
fn bench_materials() {
let mut rows = Vec::new();
for m in texturecraft_engine::materials::MATERIALS {
let mut s = texturecraft_engine::project_cmds::sample_session(1024, 256).unwrap();
let top = s.doc.project.texture_sets[0].stack.clone();
for id in top {
let _ = execute(&mut s, "layer.remove", &json!({"layer": id.0}));
}
execute(&mut s, "material.apply", &json!({"material": m.id})).unwrap();
s.caches[0].invalidate_all();
let n = s.caches[0].dirty.len();
let t = Instant::now();
s.update();
rows.push((t.elapsed() / n.max(1) as u32, m.id));
}
rows.sort();
rows.reverse();
for (per_tile, id) in rows {
println!("[materials] {id:<28} {per_tile:.2?}/tile");
}
}
/// Per-tile cost of single-layer stacks, to find fixed per-texel overhead (run single-threaded).
#[test]
#[ignore]
fn bench_single_layers() {
let cases: &[(&str, &[(&str, Value)])] = &[
("uniform fill", &[("layer.add.fill", json!({"color": [0.5, 0.5, 0.5], "roughness": 0.4}))]),
("fill + clouds", &[("layer.add.fill", json!({})), ("layer.fill.set", json!({"channel": "baseColor", "procedural": "clouds"}))]),
(
"fill + triplanar clouds",
&[
("layer.add.fill", json!({})),
("layer.fill.set", json!({"channel": "baseColor", "procedural": "clouds"})),
("layer.fill.projection", json!({"projection": "triplanar"})),
],
),
(
"fill + black mask + edge wear",
&[("layer.add.fill", json!({})), ("mask.add.black", Value::Null), ("effect.add.generator", json!({"generator": "edgeWear"}))],
),
("fill + black mask + dirt", &[("layer.add.fill", json!({})), ("mask.add.black", Value::Null), ("effect.add.generator", json!({"generator": "dirt"}))]),
];
for (label, cmds) in cases {
let mut s = texturecraft_engine::project_cmds::sample_session(1024, 256).unwrap();
for id in s.doc.project.texture_sets[0].stack.clone() {
let _ = execute(&mut s, "layer.remove", &json!({"layer": id.0}));
}
for (id, p) in cmds.iter() {
execute(&mut s, id, p).unwrap();
}
s.update();
s.caches[0].invalidate_all();
let n = s.caches[0].dirty.len();
let t = Instant::now();
s.update();
println!("[single] {label:<32} {:.2?}/tile ({n} tiles)", t.elapsed() / n.max(1) as u32);
}
}
/// The frame where an 8K stroke ends: `end_stroke` and the first budgeted update after it.
#[test]
#[ignore]
fn bench_8k_stroke_end() {
let mut s = texturecraft_engine::project_cmds::sample_session(2048, 256).unwrap();
s.viewport = Vec2::new(1700.0, 1230.0);
execute(&mut s, "textureSet.resolution", &json!({"resolution": 8192})).unwrap();
execute(&mut s, "layer.add.paint", &Value::Null).unwrap();
s.update();
upload_bytes(&mut s);
for size in [0.02, 0.08] {
s.tool.brush.size = size;
for rep in 0..3 {
s.begin_stroke(Vec2::new(500.0, 500.0 + rep as f32 * 40.0), s.viewport, 1.0).unwrap();
for i in 0..60 {
let p = Vec2::new(500.0 + i as f32 * 10.0, 500.0 + rep as f32 * 40.0 + i as f32 * 3.3);
s.continue_stroke(p, 1.0);
s.update();
upload_bytes(&mut s);
}
let t = Instant::now();
s.end_stroke().unwrap();
let end = t.elapsed();
let tiles = s.caches[0].dirty.len();
let t = Instant::now();
s.update();
let after = t.elapsed();
let t = Instant::now();
let up = upload_bytes(&mut s);
println!("[8k] size {size}: end_stroke {end:.2?}, update {after:.2?} ({tiles} tiles), display {:.2?} ({:.1} MB)", t.elapsed(), mb(up));
}
}
}
#[test]
#[ignore]
fn bench_8k_resize() {
let mut s = texturecraft_engine::project_cmds::sample_session(2048, 256).unwrap();
execute(&mut s, "layer.add.paint", &Value::Null).unwrap();
execute(&mut s, "tool.stroke", &json!({"points": [[300, 300], [700, 500]]})).unwrap();
s.update();
let t = Instant::now();
execute(&mut s, "textureSet.resolution", &json!({"resolution": 8192})).unwrap();
println!("[resize] 2K → 8K command {:.2?}", t.elapsed());
// The UI's way: the worker thread's part, then what the frame that applies it pays.
let t = Instant::now();
let out = s.resize_task(0, 4096).unwrap().run(&texturecraft_engine::bake::Progress::new()).unwrap();
let worker = t.elapsed();
let t = Instant::now();
s.apply_resize(out).unwrap();
println!("[resize] 8K → 4K worker {worker:.2?}, apply {:.2?}", t.elapsed());
let t = Instant::now();
let out = s.resize_task(0, 8192).unwrap().run(&texturecraft_engine::bake::Progress::new()).unwrap();
let worker = t.elapsed();
let t = Instant::now();
s.apply_resize(out).unwrap();
println!("[resize] 4K → 8K worker {worker:.2?}, apply {:.2?}", t.elapsed());
let t = Instant::now();
let gbuf = texturecraft_engine::geom::UvGBuffer::new(&s.mesh, 0, 8192, 8192);
println!("[resize] gbuffer {:.2?}", t.elapsed());
let t = Instant::now();
let d = texturecraft_engine::texel::DilationMap::in_blocks(&gbuf.coverage_mask(), 8192, 8192, Some(8), 8192);
println!("[resize] dilation {:.2?}", t.elapsed());
drop(d);
}