Skip to content
This repository has been archived by the owner on Mar 26, 2024. It is now read-only.

Frontend

NatrixAeria edited this page Jul 16, 2019 · 42 revisions

Diese Seite soll NICHT „Jan's Blog“ werden! Mitmachen tut nicht weh!


Der Frontend ist in Javascript und Rust(WebAssembly) geschrieben. Es wird ohne emscripten kompiliert, sondern stattdessen über wasm32-unknown-unknown und anschließend mit wasm-bindgen bearbeitet.

Entwicklungsort

wasm-branch: https://github.com/TrueDoctor/DiscoBot/tree/wasm/webhogg/wasm

Support

Aktuell wird leider nicht Firefox (selbst nicht die nightly versionen) unterstützt, weil dieser keine ausreichend ausgeprägte Unterstützung für die OffscreenCanvas-Technologie bietet. Getestet wird auf der neuesten Version von Chromium (state 22.06.2019: 74.0.3729) und Chrome Beta.

Installation

Ihr wollt Ratatosk bei euch lokal bauen und hosten? Kein Problem! (*hüstel*)
Es werden nur folgende Dinge benötigt:

  • Rust oder rustup
  • Cargo (normalerweise beim Rust-Paket dabei)
  • Ein Betriebssystem, das sh unterstützt
  • wasm-bindgen, ein Tool zum generieren von Bindings für WebAssembly
  • das binaryen-Paket (binaryen-git im AUR) für wasm-opt
  • ein webserver (python geht natürlich auch)

Ihr clonet euch das Projekt mit git clone https://github.com/TrueDoctor/DiscoBot. Nun geht ihr auf den gewünschten Branch. Im Pfad webhogg/wasm liegt das webhogg Frontend. Dort muss nur einmal die Datei build ausgeführt werden (./build oder sh build). Fertig! Jetzt muss nur noch ein Server gehostet werden. Am einfachsten geht das über das Python-Modul http.server, also ruft ihr python3 -m http.server <port> laufen und könnt jetzt in eurem Chromium unter http://localhost:<port> euer Werk bestaunen.
Wichtig ist zurzeit noch, dass man den Offscreen Canvas unter chrome://flags/#enable-experimental-web-platform-features aktiviert.

Prost!

Was, wenn ich einen Buildfehler habe?

Tja Pech gehabt.
Naja ihr habt immernoch die möglichkeit Jan zu PMmen, aber das ist nicht nötig, wenn ihr folgenden Fehler habt:

it looks like the Rust project used to create this wasm file was linked against
a different version of wasm-bindgen than this binary:

  rust wasm file: w.x.yz
     this binary: a.b.cd (blablabla)

wichtig ist, dass die von euch installierte Version von wasm-bindgen die gleiche ist, wie die, die in Cargo.toml steht. Das heißt, ihr habt zwei Möglichkeiten: ihr schreibt in die Cargo.toml eure Version von wasm-bindgen -V oder ihr up-/downdatet eurer wasm-bindgen auf die Version im Cargo.toml

Aktuelle Themen

Implementation von shared memory in threads

Die Idee ist, den Speicher zwischen den beiden in den verschiedenen Workern laufenden Webassembly Instanzen zu teilen:

Dies geschieht über die SharedArrayBuffers, an denen wohl unter anderem wegen spectre leider wenig gearbeitet wird, weshalb ihre Funktionalität recht schlecht (ganz besonders im Bereich wasm-threading) dokumentiert ist.

Unser bisheriges Vorgehen ist, im main-thread den Speicher zu erstellen:

let sharedMemory = new WebAssembly.Memory({
         initial: 1000,  // Speicher ist in 1024-Byte Blöcken angegeben
         maximum: 1000,
         shared: true
});

dieser wird dann über worker.postMessage(sharedMemory); an die Worker geschickt. Diese übergieben den sharedMemony an die von wasm-bindgen generierten init-Funktion. Damit die init-Funktion als zweites Argument den sharedMemory nimmt, muss die wasm binary entsprechend gelinkt werden. Das klappt leider bisher noch nicht so ganz… hierfür ist nachwievor help acquired!

Im Disassembly mit shared memory ist vor allem folgende Zeile wichtig:

(import "wbg" "memory" (memory $1 (shared 17 16384)))

Kompilert wird das ganze vereinfacht mit:

cargo build --target wasm32-unknown-unknown
wasm-bindgen --remove-producers-section --remove-name-section
# wichtig ist hier das --enable-threads
wasm-opt --memory-packing --remove-memory --remove-unused-brs --enable-threads -Oz

wobei der Linker (wahrscheinlich wasm-ld) mit folgenden Argumenten ausgeführt wird (als Hilfe kann man einfach wasm-ld --help eingeben):

wasm-ld --no-entry
        --allow-undefined
        --strip-all
        --export-dynamic
        --import-memory  # WICHTIG
        --shared-memory  # WICHTIG
        --max-memory=1073741824  # (2^30) WICHTIG
        --threads  # NICHT wichtig. Der Linker läuft nur multi-threaded

Die Linkerargumente werden unter .cargo/config spezifiziert. Entscheidend ist, dass man nicht code-kiddy-mäßig einfach irgendwelche Internetforen rezitiert (*hust* https://github.com/K0IN/WebassemblySimpleMultithreading/blob/master/compile.bat) und --import-table als Argument mitangibt, ohne zu wissen, was das eigentlich ist (WebAssembly.Table).

Und YAAAY 🎉 wir haben einen RuntimeError:

Uncaught (in promise) RuntimeError: unreachable
    at wasm-function[67]:445
    at wasm-function[28]:15
    at Function.__exports.start_graphics (http://localhost:8080/bin/webhogg-wasm.js:28:21)
    at http://localhost:8080/pkg/worker.js:11:26
(anonymous) @ wasm-0004bcba-67:195
(anonymous) @ wasm-0004bcba-28:8
__exports.start_graphics @ webhogg-wasm.js:28
(anonymous) @ worker.js:11
Promise.then (async)
onmessage @ worker.js:8

Das ist insofern „besser“, als dass kein Compiler Fehler mehr erscheint…

Jetzt gilt es, den Fehler einzugrenzen. Nach vielem herumprobieren merkt man, dass Aufrufe nach Javascript, wie z.B. console.log und nutzen von Strings ein buggy Verhalten haben. Das macht es natürlich schwer zu debuggen. Aber es gibt immernoch die möglichkeit, dass exportierte Funktionen Werte zurückgeben. Also lassen wir die in der Schleife laufenden Funktionen loop_graphics und loop_logic Werte zurückgeben, um so eine Interaktion zwischen Nutzerebene und wasm herzustellen. Hier ein Beispiel, um zu demonstrieren, dass shared memory tatsächlich funktioniert:

#[wasm_bindgen]
pub fn loop_logic(val: usize) -> u32 {
    unsafe {
        let b: Box<u32> = Box::from_raw(64000 as *mut u32);
        let b = Box::leak(b);
        *b
    }
}
#[wasm_bindgen]
pub fn loop_graphics(val: usize) -> u32 {
    unsafe {
        let b: Box<u32> = Box::from_raw(64000 as *mut u32);
        let b = Box::leak(b);
        *b += 1;
        *b
    }
}

und in der Ausgabe sieht man, dass logic den Wert tatsächlich auslesen kann:

worker.js:25 logic counter:  0
worker.js:18 graphics counter:  1
worker.js:25 logic counter:  1
worker.js:18 graphics counter:  2
worker.js:25 logic counter:  2
worker.js:18 graphics counter:  3
worker.js:25 logic counter:  3
worker.js:18 graphics counter:  4
worker.js:25 logic counter:  4

Wenn man jetzt in den wasm code sieht, dann erkennt man, dass er stark geschrumpft und übersichtlicher geworden ist:

(module
 (type $0 (func (param i32)))
 (type $1 (func (param i32) (result i32)))
 (type $2 (func))
 (import "wbg" "memory" (memory $1 (shared 17 16384)))
 (import "wbg" "__wbindgen_object_drop_ref" (func $fimport$0 (param i32)))
 (export "start_graphics" (func $0))
 (export "loop_graphics" (func $1))
 (export "start_logic" (func $3))
 (export "loop_logic" (func $2))
 (func $0 (; 1 ;) (type $0) (param $0 i32)
  (i32.store
   (i32.const 64000)
   (i32.const 0)
  )
  (if
   (i32.ge_u
    (local.get $0)
    (i32.const 36)
   )
   (call $fimport$0
    (local.get $0)
   )
  )
 )
 (func $1 (; 2 ;) (type $1) (param $0 i32) (result i32)
  (i32.store
   (i32.const 64000)
   (local.tee $0
    (i32.add
     (i32.load
      (i32.const 64000)
     )
     (i32.const 1)
    )
   )
  )
  (local.get $0)
 )
 (func $2 (; 3 ;) (type $1) (param $0 i32) (result i32)
  (i32.load
   (i32.const 64000)
  )
 )
 (func $3 (; 4 ;) (type $2)
  (nop)
 )
)

Globale Variablen sind übrigens auch möglich und der Weg, wie wir ganze in Zukunft wohl handeln wollen:

static mut number: u32 = 0;

#[wasm_bindgen]
pub fn loop_logic(val: usize) -> u32 {
    unsafe { number }
}
#[wasm_bindgen]
pub fn loop_graphics(val: usize) -> u32 {
    unsafe {
        number += 1;
        number
    }
}

Problematisch ist es allerdings im Moment noch, wenn ich Mutexes nutzen will:

static mut MEMORY: Option<Mutex<SharedMemory>> = None;

pub unsafe fn init_memory() { MEMORY = SharedMemory::default() }

Das gibt aus unerklärlichen Gründen einen RuntimeError: unreachable. Vermutlich ist der allocator an dem Schlamassel Schuld, dass so gut wie alles in der Ausführung einen RuntimeError wirft, aber wer weiß. Jan hat bereits versucht, den allocator auf wee_alloc zu wechseln, aber das hat nichts geändert, außer dass die binary 5KB größer wurde…

Jan ist seeehr verwirrt 😕

😢

😭

PLEEEEAAASEEEEE

😢

SOME HELP PLEEEAAASEEE! 😭 😭 😭 😭


Okay... 😥 *phew* durchatmen.

Wenn wir debuggen wollen, können wir leider nicht console.log(&str/String) nutzen, aber irgendweshalb console.log(u32). Aber immerhin haben wir jetzt irgendeine möglichkeit, zu debuggen.

Schon einfache Dinge, wie Strings geben Fehler: :x:

// gibt einen Fehler!
for i in "hallo welt".to_owned().bytes() {
    logger::log_num(i.into());
}

Während folgendes fehlerfrei läuft: :heavy_check_mark:

// läuft!
for i in &[50, 60, 70, 80] {
    logger::log_num(*i);
}

Bei dem String-Beispiel ist wohl das .to_owned Schuld am Absturz, denn wenn wir den .bytes() Iterator über einen &str statt über einen String laufen lassen, stürzt das Programm nicht mehr ab:

// läuft!
for i in "hallo welt".bytes() {
    logger::log_num(i.into());
}

Allerdings kommt der unerwartete Output:

104
0
0
0
0
0
…

Das erste Byte ist richtig, aber der Rest sind einfach Nullen?! Was soll das? Wollen wir doch mal in den Code von to_owned sehen:

impl ToOwned for str {
    fn to_owned(&self) -> String {
        unsafe { String::from_utf8_unchecked(self.as_bytes().to_owned())
    }
}

Okay, dann sehen wir mal, wo der Fehler kommt. Bei as_bytes, bytes.to_owned oder bei String::from_utf8_unchecked?

as_bytes

Probieren wir doch mal:

for &i in "hallo welt".as_bytes() {
    logger::log_num(i.into());
}

Output:

104
97
108
108
111
32
119
101
108
116

Das ist exakt "hallo welt". Hä?!
Wenn wir uns den code von as_bytes ansehen:

pub fn as_bytes(&self) -> &[u8] {
    &self.vec
}

sehen wir, dass das wahrscheinlich auf das gleiche hinauskommt, wie auf einem hardcoded array zu iterieren.

Aber wenigstens haben wir den Fehler auf bytes.to_owned und String::from_utf8_unchecked eingeschränkt.

bytes.to_owned

impl<T: Clone> ToOwned for [T] {
    #[cfg(not(test))]
    fn to_owned(&self) -> Vec<T> {
        self.to_vec()
    }
}

Also probieren wir doch mal:

for i in "hallo welt".as_bytes().to_owned() {
    logger::log_num(i.into());              
}

Fehler! ❌

🤖: Error located!

[u8].to_vec

#[cfg(not(test))]
impl<T> [T] {
    #[inline]
    pub fn to_vec(&self) -> Vec<T> {
        hack::to_vec(self)
    }
}
mod hack {
    #[inline]
    pub fn to_vec<T>(s: &[T]) -> Vec<T> {
        let mut vector = Vec::with_capacity(s.len());
        vector.extend_from_slice(s);
        vector
    }
}

ist es etwa das Vec::with_capacity?

use crate::raw_vec::RawVec;
impl<T> Vec<T> {
    #[inline]
    pub fn with_capacity(capacity: usize) -> Vec<T> {
        Vec {
            buf: RawVec::with_capacity(capacity),
            len: 0,
        }
    }
}

Global ist der globale memory allocator

impl<T, A: Alloc> RawVec<T, A> {
    fn allocate_in(cap: usize, zeroed: bool, mut a: A) -> Self {
        unsafe {
            let elem_size = mem::size_of::<T>();

            let alloc_size = cap.checked_mul(elem_size).unwrap_or_else(|| capacity_overflow());
            alloc_guard(alloc_size).unwrap_or_else(|_| capacity_overflow());

            // handles ZSTs and `cap = 0` alike
            let ptr = if alloc_size == 0 {
                NonNull::<T>::dangling()
            } else {
                let align = mem::align_of::<T>();
                let layout = Layout::from_size_align(alloc_size, align).unwrap();
                let result = if zeroed {
                    a.alloc_zeroed(layout)
                } else {
                    a.alloc(layout)
                };
                match result {
                    Ok(ptr) => ptr.cast(),
                    Err(_) => handle_alloc_error(layout),
                }
            };

            RawVec {
                ptr: ptr.into(),
                cap,
                a,
            }
        }
    }
}

// gilt nur für den globalen Allocator!
impl<T> RawVec<T, Global> {
    #[inline]
    pub fn with_capacity(cap: usize) -> Self {
        RawVec::allocate_in(cap, false, Global)
    }
}

10 years later

Der Fehler war all die Zeit ein optimizer flag --remove-memory. Ohne diese "Optimierung" funktionier auch wieder alles. Den Allocator muss man aber trotzdem überschreiben. Um das Projekt neu zu strukturieren, schreiben wir den Code neu unter /webhogg/client/, ist ja schließlich noch nicht viel.
Wir haben jetzt 3 Crates:

In webhogg-wasm-shared steht jetzt ein Allocator:

impl MutableAllocator {
    unsafe fn alloc(&mut self, layout: Layout) -> *mut u8 {
        let size = layout_to_size(layout);
        let pos = self.pos;
        self.pos += size;
        pos as *mut u8
    }

    #[allow(unused_variables)]
    unsafe fn dealloc(&mut self, ptr: *mut u8, layout: Layout) {
    }
}

Folgendes Programm funktioniert jetzt:

logic/entries.rs

#[wasm_bindgen]
pub fn init() {
    unsafe {
        crate::ALLOCATOR.reset();
    }
    unsafe {
        let addr = ADDR as *mut u32;
        *addr = 0;
    }
    log("logic entry reached");
}

#[wasm_bindgen]
pub fn frame() {
    unsafe {
        let addr = ADDR as *mut u32;
        log(&format!("num: {}", *addr));
    }
}

graphics/entries.rs

#[wasm_bindgen]
pub fn init() {
    unsafe {
        crate::ALLOCATOR.reset();
    }
    log("graphics entry reached");
}

#[wasm_bindgen]
pub fn frame() {
    unsafe {
        let addr = ADDR as *mut u32;
        *addr += 1;
    }
}

Output

logic entry reached
graphics.js:39 graphics entry reached
logic.js:39 num: 0
logic.js:39 num: 1
logic.js:39 num: 2
logic.js:39 num: 3

Die große Frage

Wie kommunizieren wir über den shared memory?

Es soll die Logik der Grafik Informationen übermitteln können.

Wieso können wir nicht einfach einen geteilten Speicherbereich haben, wenn nur eine*r schreibt und eine*r liest?

Geht nicht. Stellt euch folgendes Szenario vor: Der geteilte Speicherbereich hat 2048 Bytes:\

| 0000000000000000 |

Jetzt überschreibt die Logik den Speicherbereich (kann es nur Byte für Byte):

 Logik
 v
| 0000000000000000 |
     Logik
     v
| 1110000000000000 |

Nun kommt die Grafik und ließt. Grafik kann aber schneller lesen, als Logik schreiben kann:

Grafik Logik
 v     v
| 1111100000000000 |
         Logik  Grafik
         v      v
| 1111111000000000 |

Jetzt hat die Grafik zur einen Hälfte den alten Status, zur anderen Hälfte aber den neuen Status gelesen. Doof.


See Also

Navigation

Brainstorming:
      Sessions Liste 📃
      Letzte Session ◀️
      Nächste Session ▶️
      Last Design-Session 👈
      Next Design-Session 👉
      Dunkle Seite 🌈
Design:
      Sound 🎧
      Grafikdesign 🤺
      Animationen 🎞️
      Gamedesign 📝
Programmierung:
      Gamelogik ⚙️
      Frontend 👾
      Backend 🗄️
Spielprotokoll 🧻

Clone this wiki locally