Skip to content

Commit

Permalink
Implement Web Renderer
Browse files Browse the repository at this point in the history
This adds a new renderer for the web that renders to a canvas, which can
then be easily attached anywhere in the DOM with any size. This should
allow the web LiveSplit One to replace its React / HTML based renderer,
bringing the rendering more in line with the native renderer, with
features like arbitrary resizing and horizontal splits. Additionally
this should improve the performance a lot and help remove a lot of the
code from the web LiveSplit One.
  • Loading branch information
CryZe committed Apr 25, 2024
1 parent 8f0f62b commit 933abaa
Show file tree
Hide file tree
Showing 22 changed files with 1,186 additions and 125 deletions.
18 changes: 13 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ jobs:

- label: WebAssembly WASI
target: wasm32-wasi
auto_splitting: skip
cross: skip
dylib: skip
install_target: true
Expand Down Expand Up @@ -411,14 +412,14 @@ jobs:
# macOS
- label: macOS aarch64
target: aarch64-apple-darwin
os: macos-14
os: macos-latest
cross: skip
install_target: true

- label: macOS x86_64
target: x86_64-apple-darwin
os: macos-13
os: macos-latest
cross: skip
install_target: true

# iOS
- label: iOS aarch64
Expand Down Expand Up @@ -532,14 +533,14 @@ jobs:
release: skip

- label: macOS Beta
target: x86_64-apple-darwin
target: aarch64-apple-darwin
os: macOS-latest
toolchain: beta
release: skip
cross: skip

- label: macOS Nightly
target: x86_64-apple-darwin
target: aarch64-apple-darwin
os: macOS-latest
toolchain: nightly
release: skip
Expand Down Expand Up @@ -601,6 +602,13 @@ jobs:
SKIP_NETWORKING: ${{ matrix.networking }}
SKIP_SOFTWARE_RENDERING: ${{ matrix.software_rendering }}

- name: Upload screenshots
if: matrix.tests == '' && (success() || failure())
uses: actions/upload-artifact@v4
with:
name: Screenshots ${{ matrix.label }}
path: target/renders

- name: Prepare Release
if: startsWith(github.ref, 'refs/tags/') && matrix.release == ''
shell: bash
Expand Down
4 changes: 1 addition & 3 deletions .github/workflows/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ main() {
local cargo=cross

# all features except those that sometimes should be skipped.
local features="--features std,more-image-formats,image-shrinking,rendering,default-text-engine,wasm-web,font-loading"
local features="--features std,more-image-formats,image-shrinking,rendering,svg-rendering,default-text-engine,wasm-web,web-rendering,font-loading"

if [ "$SKIP_CROSS" = "skip" ]; then
cargo=cargo
Expand All @@ -25,8 +25,6 @@ main() {
if [ "$TARGET" = "wasm32-wasi" ]; then
curl https://wasmtime.dev/install.sh -sSf | bash
export PATH="$HOME/.wasmtime/bin:$PATH"
$cargo test -p livesplit-core --features software-rendering --target $TARGET
return
fi

$cargo test -p livesplit-core $features --target $TARGET
Expand Down
19 changes: 17 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ tokio = { version = "1.24.2", default-features = false, features = [
], optional = true }
log = { version = "0.4.14", default-features = false, optional = true }

[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies]
# WebAssembly in the Web
js-sys = { version = "0.3.55", optional = true }
wasm-bindgen = { version = "0.2.78", optional = true }
wasm-bindgen-futures = { version = "0.4.28", optional = true }
web-sys = { version = "0.3.28", default-features = false, features = [
"Performance",
"Window",
Expand All @@ -125,7 +126,7 @@ seahash = "4.1.0"
[target.'cfg(windows)'.dev-dependencies]
sysinfo = { version = "0.30.0", default-features = false }

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
criterion = "0.5.0"

[features]
Expand Down Expand Up @@ -165,6 +166,20 @@ default-text-engine = ["rendering", "cosmic-text"]
font-loading = ["std", "default-text-engine"]
software-rendering = ["default-text-engine", "tiny-skia", "tiny-skia-path"]
svg-rendering = ["default-text-engine", "ahash"]
web-rendering = [
"wasm-web",
"rendering",
"wasm-bindgen-futures",
"web-sys/Blob",
"web-sys/CanvasGradient",
"web-sys/Document",
"web-sys/DomRect",
"web-sys/Element",
"web-sys/HtmlCanvasElement",
"web-sys/ImageBitmap",
"web-sys/Path2d",
"web-sys/TextMetrics",
]
wasm-web = [
"std",
"cosmic-text?/wasm-web",
Expand Down
15 changes: 11 additions & 4 deletions benches/scene_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ cfg_if::cfg_if! {
use livesplit_core::{
layout::{self, Layout},
rendering::{
PathBuilder, ResourceAllocator, SceneManager, Label, FontKind, SharedOwnership,
PathBuilder, ResourceAllocator, SceneManager,
Image, Label, FontKind, SharedOwnership,
},
run::parser::livesplit,
settings::{Font, ImageCache},
Expand All @@ -31,15 +32,15 @@ cfg_if::cfg_if! {
impl ResourceAllocator for Dummy {
type PathBuilder = Dummy;
type Path = ();
type Image = ();
type Image = Dummy;
type Font = ();
type Label = Dummy;

fn path_builder(&mut self) -> Self::PathBuilder {
Dummy
}
fn create_image(&mut self, _: &[u8]) -> Option<(Self::Image, f32)> {
Some(((), 1.0))
fn create_image(&mut self, _: &[u8]) -> Option<Self::Image> {
Some(Dummy)
}
fn create_font(&mut self, _: Option<&Font>, _: FontKind) -> Self::Font {}
fn create_label(
Expand Down Expand Up @@ -68,6 +69,12 @@ cfg_if::cfg_if! {
}
}

impl Image for Dummy {
fn aspect_ratio(&self) -> f32 {
1.0
}
}

impl SharedOwnership for Dummy {
fn share(&self) -> Self {
Dummy
Expand Down
4 changes: 4 additions & 0 deletions capi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ serde_json = { version = "1.0.8", default-features = false }
time = { version = "0.3.4", default-features = false, features = ["formatting"] }
simdutf8 = { git = "https://github.com/CryZe/simdutf8", branch = "wasm-ub-panic", default-features = false }

wasm-bindgen = { version = "0.2.78", optional = true }
web-sys = { version = "0.3.28", optional = true }

[features]
default = ["image-shrinking"]
image-shrinking = ["livesplit-core/image-shrinking"]
software-rendering = ["livesplit-core/software-rendering"]
wasm-web = ["livesplit-core/wasm-web"]
auto-splitting = ["livesplit-core/auto-splitting"]
assume-str-parameters-are-utf8 = []
web-rendering = ["wasm-web", "livesplit-core/web-rendering", "wasm-bindgen", "web-sys"]
30 changes: 12 additions & 18 deletions capi/bind_gen/src/wasm_bindgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,10 +387,8 @@ declare namespace TextEncoding {
}
}
const encoder = new TextEncoder("UTF-8");
const decoder = new TextDecoder("UTF-8");
const encodeUtf8: (str: string) => Uint8Array = (str) => encoder.encode(str);
const decodeUtf8: (data: Uint8Array) => string = (data) => decoder.decode(data);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
interface Slice {
ptr: number,
Expand All @@ -408,13 +406,12 @@ function allocUint8Array(src: Uint8Array): Slice {
}
function allocString(str: string): Slice {
const stringBuffer = encodeUtf8(str);
const len = stringBuffer.length + 1;
const len = 3 * str.length + 1;
const ptr = wasm.alloc(len);
const slice = new Uint8Array(wasm.memory.buffer, ptr, len);
slice.set(stringBuffer);
slice[len - 1] = 0;
const stats = encoder.encodeInto(str, slice);
slice[stats.written] = 0;
return { ptr, len };
}
Expand All @@ -431,7 +428,7 @@ function decodePtrLen(ptr: number, len: number): Uint8Array {
}
function decodeString(ptr: number): string {
return decodeUtf8(decodeSlice(ptr));
return decoder.decode(decodeSlice(ptr));
}
function dealloc(slice: Slice) {
Expand All @@ -448,10 +445,8 @@ function dealloc(slice: Slice) {
r#"import * as wasm from "./livesplit_core_bg.wasm";
import "./livesplit_core.js";
const encoder = new TextEncoder("UTF-8");
const decoder = new TextDecoder("UTF-8");
const encodeUtf8 = (str) => encoder.encode(str);
const decodeUtf8 = (data) => decoder.decode(data);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function allocUint8Array(src) {
const len = src.length;
Expand All @@ -464,13 +459,12 @@ function allocUint8Array(src) {
}
function allocString(str) {
const stringBuffer = encodeUtf8(str);
const len = stringBuffer.length + 1;
const len = 3 * str.length + 1;
const ptr = wasm.alloc(len);
const slice = new Uint8Array(wasm.memory.buffer, ptr, len);
slice.set(stringBuffer);
slice[len - 1] = 0;
const stats = encoder.encodeInto(str, slice);
slice[stats.written] = 0;
return { ptr, len };
}
Expand All @@ -487,7 +481,7 @@ function decodePtrLen(ptr, len) {
}
function decodeString(ptr) {
return decodeUtf8(decodeSlice(ptr));
return decoder.decode(decodeSlice(ptr));
}
function dealloc(slice) {
Expand Down
17 changes: 10 additions & 7 deletions capi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ pub mod timer_write_lock;
pub mod title_component;
pub mod title_component_state;
pub mod total_playtime_component;
#[cfg(all(target_family = "wasm", feature = "web-rendering"))]
pub mod web_rendering;

use crate::{
run_metadata_custom_variable::RunMetadataCustomVariable,
Expand Down Expand Up @@ -211,18 +213,19 @@ unsafe fn get_file(_: i64) -> ManuallyDrop<File> {
#[cfg(all(target_family = "wasm", not(target_os = "wasi")))]
#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
core::mem::forget(buf);
ptr
if size == 0 {
std::ptr::NonNull::dangling().as_ptr()
} else {
unsafe { std::alloc::alloc(std::alloc::Layout::from_size_align_unchecked(size, 1)) }
}
}

/// Deallocate memory.
#[cfg(all(target_family = "wasm", not(target_os = "wasi")))]
#[no_mangle]
pub extern "C" fn dealloc(ptr: *mut u8, cap: usize) {
unsafe {
let _buf = Vec::from_raw_parts(ptr, 0, cap);
pub unsafe extern "C" fn dealloc(ptr: *mut u8, cap: usize) {
if cap != 0 {
std::alloc::dealloc(ptr, std::alloc::Layout::from_size_align_unchecked(cap, 1));
}
}

Expand Down
43 changes: 43 additions & 0 deletions capi/src/web_rendering.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Provides a renderer for the web that renders into a canvas. The element can
//! then be attached anywhere in the DOM with any desired positioning and size.
//!
use livesplit_core::{layout::LayoutState, rendering::web, settings::ImageCache};
use wasm_bindgen::prelude::*;
use web_sys::Element;

/// The web renderer renders into a canvas element. The element can then be
/// attached anywhere in the DOM with any desired positioning and size.
#[wasm_bindgen]
pub struct CanvasRenderer {
inner: web::Renderer,
}

#[wasm_bindgen]
impl CanvasRenderer {
/// Creates a new web renderer that renders into a canvas element. The
/// element can then be attached anywhere in the DOM with any desired
/// positioning and size. There are two CSS fonts that are used as the
/// default fonts. They are called "timer" and "fira". Make sure they are
/// fully loaded before creating the renderer as otherwise information about
/// a fallback font is cached instead.
#[allow(clippy::new_without_default)]
pub fn new() -> CanvasRenderer {
Self {
inner: web::Renderer::new(),
}
}

/// Returns the HTML element. This can be attached anywhere in the DOM with
/// any desired positioning and size.
pub fn element(&self) -> Element {
self.inner.element().clone()
}

/// Renders the layout state into the canvas. The image cache is used to
/// retrieve images that are used in the layout state.
pub unsafe fn render(&mut self, state: usize, image_cache: usize) {
let state = unsafe { core::mem::transmute::<usize, &LayoutState>(state) };
let image_cache = unsafe { core::mem::transmute::<usize, &ImageCache>(image_cache) };
self.inner.render(state, image_cache);
}
}
10 changes: 2 additions & 8 deletions src/component/title/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,10 @@ impl Component {
let unchanged = catch! {
let mut rem = &**state.line1.last()?;

let Some(rest) = rem.strip_prefix(game_name) else {
return None;
};
rem = rest;
rem = rem.strip_prefix(game_name)?;

if !game_name.is_empty() && !full_category_name.is_empty() {
let Some(rest) = rem.strip_prefix(" - ") else {
return None;
};
rem = rest;
rem = rem.strip_prefix(" - ")?;
}

if rem != full_category_name {
Expand Down
2 changes: 1 addition & 1 deletion src/platform/math.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
cfg_if::cfg_if! {
if #[cfg(feature = "std")] {
if #[cfg(all(feature = "std", not(test)))] {
pub mod f32 {
#[inline(always)]
pub fn abs(x: f32) -> f32 {
Expand Down
8 changes: 0 additions & 8 deletions src/rendering/default_text_engine/color_font/cpal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,6 @@ struct Header {
color_records_array_offset: O32,
}

#[derive(Debug, Copy, Clone, Pod, Zeroable)]
#[repr(C)]
struct HeaderV1Extra {
palette_types_array_offset: O32,
palette_labels_array_offset: O32,
palette_entry_labels_array_offset: O32,
}

#[derive(Debug, Copy, Clone, Pod, Zeroable)]
#[repr(C)]
pub struct ColorRecord {
Expand Down
3 changes: 1 addition & 2 deletions src/rendering/icon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ pub struct CachedImage<T> {
pub image: Option<ImageHandle<T>>,
}

// FIXME: Is this still useful?
pub struct ImageHandle<T> {
pub handle: Handle<T>,
pub aspect_ratio: f32,
}

impl<T: SharedOwnership> SharedOwnership for ImageHandle<T> {
fn share(&self) -> Self {
Self {
handle: self.handle.share(),
aspect_ratio: self.aspect_ratio,
}
}
}
Expand Down
Loading

0 comments on commit 933abaa

Please sign in to comment.