Worldcore

Worldcore is a multi-player 3D game engine for the web, running on Croquet. github

codefrau 🦩 — discord Worldcore is fully independent of Microverse, we released it as open source last year. It’s an entity management system introducing Actors and Pawns, composable behaviors, and supports different renderers. One of its demo apps is https://croquet.io/w3 which uses a different renderer than Microverse. Microverse adds live-programmable cards, dynamic asset import, portals, etc. Microverse uses Worldcore’s ThreeJS renderer which uses one of the most popular 3d js libraries, three.js. See https://croquet.io/docs/

YOUTUBE k5oMTaly9CE Gameplay Workshop at GDC 2021

Worldcore is an entity-management system that sits on top of Croquet. It makes it easier to wrangle large numbers of 3D objects in a multiuser app.

> […] its actor/pawn system. An Actor is a type of model that automatically instantiates a matching Pawn in the view when its created. This means you can focus on what the actor does, and how the pawn looks, without having to worry about how they communicate with each other. > Actors and pawns can be modularly extended with mixins to give them additional methods and properties. Mixins can also register actors and pawns with services. Services are global objects in the model or the view that provide shared functionality like rendering or collision detection.

DOT FROM lambda-browsing

* Actor * Pawn * ModelRoot * ViewRoot * StartWorldcore

**Assets**:

pages/world-core-tutorial-1

**Note**: We might need to find a form for a development narrative that includes a change log. Here I would like to refer in particular to the comments of Ward and Eric, who pointed me to the pattern in Static Import Snippet (`importjs.html`). > Look for eric 's variation where he avoids using eval by converting the code to be eval'd into a dataurl and then importing that inside his run-script.

The run-script here is `run-world.html`, see the following Frame.

//wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/run-world.html HEIGHT 55

* [ ] fix: TypeError: "Cannot read properties of undefined (reading 'DYNAMIC_DRAW')" * [ ] fix: parts of Cube code are interpreted as link(s), see graphviz plugin above * [ ] fix: { DrawCall, Cube } from … (see MyPawn below and imports of `tutorial1/index.js ` and webgl/src/Render.js ) ) * [x] fix: ReferenceError: "PM_Visible is not defined" * [x] fix: ReferenceError: "RenderManager is not defined", cf. ViewService.

// import { PM_WebGLVisible } from "https://unpkg.com/@croquet/worldcore-webgl@1.2.0/src/RenderManager.js" import { DrawCall, Cube } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/webglRender.js";

PM_WebGLVisible ⇒ Visible Mixin and below

~

Below this pagefold we reveal the code which does the **World Core Tutorial 1** ( js , sdk redirected to docs , github ) example in the **Frame** (`run-world.html`) above.

Imports … … of `tutorial1/index.js ` >import { ModelRoot, ViewRoot, StartWorldcore, Actor, Pawn, **mix**, AM_Spatial, PM_Spatial, PM_WebGLVisible, WebGLRenderManager, DrawCall, Cube, v3_normalize, q_axisAngle, toRad, InputManager } from "@croquet/worldcore ";

// import { ModelRoot, ViewRoot, StartWorldcore } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Root.js";

… of `Root.js `

// import { Model, View, Session } from "@croquet/croquet";

// import { ActorManager } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Actor.js";

// import { PawnManager } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Pawn.js";

import { ClearObjectCache } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/ObjectCache.js";

# WorldcoreModel Extends the model base class with Worldcore-specific methods.

export class WorldcoreModel extends Croquet.Model { service(name) { return this.wellKnownModel(name) } } WorldcoreModel.register("WorldcoreModel");

# ModelRoot

export class ModelRoot extends WorldcoreModel { static modelServices() { return []; } init() { super.init(); this.beWellKnownAs("ModelRoot"); this.services = new Set(); this.services.add(ActorManager.create()); this.constructor.modelServices().forEach( service => { let options; if (service.service) { // Process extended service object options = service.options; service = service.service; } this.services.add(service.create(options)); }); } } ModelRoot.register("ModelRoot");

# ModelService A model service is a named singleton that's created by the root model. Do not instantiate model services directly.

export class ModelService extends WorldcoreModel { static async asyncStart() {} init(name, options = {}) { super.init(); this.name = name; if (!name) console.error("All services must have public names!"); else if (this.wellKnownModel(name)) console.error("Duplicate service!"); else this.beWellKnownAs(name); } } ModelService.register('ModelService'); export function GetModelService(name) { return viewRoot.wellKnownModel(name) }

# WorldcoreView Extends the view base class with Worldcore-specific methods.

export class WorldcoreView extends Croquet.View { service(name) { return viewServices.get(name) } modelService(name) { return this.wellKnownModel(name) } get time() {return time1} get delta() {return time1 - time0} }

# ViewRoot viewRoot is a special public global variable that stores the viewRoot.

export let viewRoot; let time0 = 0; let time1 = 0; const viewServices = new Map(); export class ViewRoot extends WorldcoreView { static viewServices() { return []; } constructor(model) { super(model); this.model = model; viewRoot = this; time0 = 0; time1 = 0; viewServices.clear(); ClearObjectCache(); this.constructor.viewServices().forEach( service => { let options; let name = service.name; // either the class name, or the name property; if (service.service) { // Process extended service object options = service.options; service = service.service; } new service(options, name); }); new PawnManager(); } detach() { [...viewServices.values()].reverse().forEach(s => s.destroy()); viewServices.clear(); super.detach(); } update(time) { time0 = time1; time1 = time; const delta = time1 - time0; let done = new Set(); viewServices.forEach(s => { if (done.has(s)) {return;} done.add(s); if (s.update) s.update(time, delta); }); } }

# ViewService

export class ViewService extends WorldcoreView { static async asyncStart() {} constructor(name) { super(viewRoot.model); this.model = viewRoot.model; this.registerViewName(name); } registerViewName(name) { if (!name) console.error("All services must have public names!"); else if (viewServices.has(name)) console.error("Duplicate service!"); else viewServices.set(name, this); } destroy() { this.detach(); viewServices.delete(this.name); } } export function GetViewService(name) { return viewServices.get(name) }

# RenderManager cf. `Render.js `

export class RenderManager extends ViewService { constructor(options = {}, name) { super(name); this.registerViewName("RenderManager"); // Alternate generic name this.layers = {}; } dirtyLayer(name) {} // Renderer can use this to trigger a rebuild of renderer-specific layer data; }

# WebGLRenderManager The top render interface that controls the execution of draw passes. (webgl/src/RenderManager.js )

export class WebGLRenderManager extends RenderManager { constructor(options, name) { super(options, name || "WebGLRenderManager"); SetGLPipeline(this); this.display = new MainDisplay(); this.buildBuffers(); this.buildShaders(); this.setScene(new Scene()); this.setCamera(new Camera()); this.setLights(new Lights()); this.setBackground([0,0,0,1]); } destroy() { this.display.destroy(); if (this.geometryBuffer) this.geometryBuffer.destroy(); if (this.aoBuffer) this.aoBuffer.destroy(); if (this.composeBuffer) this.composeBuffer.destroy(); } buildBuffers() { if (GetGLVersion() === 0) return; this.geometryBuffer = new GeometryBuffer({autoResize: 1}); this.aoBuffer = new Framebuffer({autoResize: 0.5}); this.composeBuffer = new SharedStencilFramebuffer(this.geometryBuffer); this.composeBuffer.setBackground([1,1,1,1]); } buildShaders() { if (GetGLVersion() === 0) { this.basicShader = new BasicShader(); this.decalShader = new DecalShader(); this.translucentShader = new TranslucentShader(); } else { this.instancedShader = new InstancedShader(); this.decalShader = new DecalShader(); this.instancedDecalShader = new InstancedDecalShader(); this.translucentShader = new TranslucentShader(); this.geometryShader = new GeometryShader(); this.instancedGeometryShader = new InstancedGeometryShader(); this.translucentGeometryShader = new TranslucentGeometryShader(); this.passthruShader = new PassthruShader(); this.blendShader = new BlendShader(); this.aoShader = new AOShader(); } } setLights(lights) { this.lights = lights; } setCamera(camera) { SetGLCamera(camera); this.camera = camera; if (GetGLVersion() === 0) { this.display.setCamera(camera); } else { this.geometryBuffer.setCamera(camera); } } // Updates shaders that depend on the camera FOV. updateFOV() { if (this.aoShader) this.aoShader.setFOV(this.camera.fov); } setBackground(background) { this.background = background; this.display.setBackground(background); if (GetGLVersion() > 0) this.geometryBuffer.setBackground(background); } setScene(scene) { this.scene = scene; } update() { this.draw(); } draw() { if (!this.scene) return; if (GetGLVersion() === 0) { this.drawForward(); } else { this.drawDeferred(); } } drawDeferred() { // Geometry Pass this.geometryBuffer.start(); StartStencilCapture(); this.geometryShader.apply(); this.lights.apply(); this.camera.apply(); this.scene.drawPass('opaque'); this.instancedGeometryShader.apply(); this.lights.apply(); this.camera.apply(); this.scene.drawPass('instanced'); EndStencil(); this.translucentGeometryShader.apply(); this.camera.apply(); this.scene.drawPass('translucent'); // Lighting Pass this.aoBuffer.start(); this.aoShader.apply(); this.geometryBuffer.normal.apply(0); this.geometryBuffer.position.apply(1); this.aoBuffer.draw(); StartStencilApply(); this.composeBuffer.start(); // The ao pass leaves the sky black. This uses the stencil to make the sky white. this.passthruShader.apply(); this.aoBuffer.texture.apply(0); this.composeBuffer.draw(); EndStencil(); this.display.start(); this.blendShader.apply(); this.geometryBuffer.diffuse.apply(0); this.composeBuffer.texture.apply(1); this.display.draw(); } drawForward() { this.display.start(); this.decalShader.apply(); this.lights.apply(); this.camera.apply(); this.scene.drawPass('opaque'); this.lights.apply(); this.camera.apply(); this.scene.drawPass('instanced'); this.translucentShader.apply(); this.camera.apply(); this.scene.drawPass('translucent'); } }

# StartWorldcore

export async function StartWorldcore(options) { await Promise.all(options.model.modelServices().map(service => { if (service.service) service = service.service; return service.asyncStart(); })); await Promise.all(options.view.viewServices().map(service => { if (service.service) service = service.service; return service.asyncStart(); })); const session = await Croquet.Session.join(options); return session; }

… `Actor.js `

// import { Actor } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Actor.js";

// import { Pawn } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Pawn.js";

// import { ModelService, WorldcoreModel } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Root.js";

# ActorManager

export class ActorManager extends ModelService { init(name) { super.init(name || 'ActorManager'); this.actors = new Map(); } add(actor) { this.actors.set(actor.id, actor); } has(id) { return this.actors.has(id); } get(id) { return this.actors.get(id); } delete(actor) { this.actors.delete(actor.id); } } ActorManager.register("ActorManager");

# Actor

export class Actor extends WorldcoreModel { get pawn() {return Pawn} get doomed() {return this._doomed} // About to be destroyed. This is used to prevent creating new future messages. init(options) { super.init(); this.listen("_set", this.set); this.set(options); this.service('ActorManager').add(this); this.publish("actor", "createActor", this); } destroy() { this._doomed = true; // About to be destroyed. This is used to prevent creating new future messages. this.say("destroyActor"); this.service('ActorManager').delete(this); super.destroy(); } set(options = {}) { const sorted = Object.entries(options).sort((a,b) => { return b[0] < a[0] ? 1 : -1 } ); for (const option of sorted) { const n = "_" + option[0]; const v = option[1]; const o = this[n]; this[n] = v; this.say(n, {v, o}); // Publish a local message whenever a property changes with its old and new value. } } say(event, data) { this.publish(this.id, event, data); } listen(event, callback) { this.subscribe(this.id, event, callback); } ignore(event) { this.unsubscribe(this.id, event); } actorFromId(id) { return this.service("ActorManager").get(id); } } Actor.register("Actor");

… `Pawn.js `

# PawnManager

// import { ViewService, WorldcoreView } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Root.js";

let pm; // Local pointer for pawns export class PawnManager extends ViewService { constructor(name) { super(name || "PawnManager"); pm = this; this.pawns = new Map(); this.dynamic = new Set(); const actorManager = this.modelService("ActorManager"); actorManager.actors.forEach(actor => this.spawnPawn(actor)); this.subscribe("actor", "createActor", this.spawnPawn); } destroy() { const doomed = new Map(this.pawns); doomed.forEach(pawn => pawn.destroy()); pm = null; super.destroy(); } spawnPawn(actor) { new actor.pawn(actor); } add(pawn) { this.pawns.set(pawn.actor.id, pawn); } has(id) { return this.pawns.has(id); } get(id) { return this.pawns.get(id); } delete(pawn) { this.pawns.delete(pawn.actor.id); } addDynamic(pawn) { this.dynamic.add(pawn); } deleteDynamic(pawn) { this.dynamic.delete(pawn); } update(time, delta) { this.dynamic.forEach( pawn => { if (pawn.parent) return; // Child pawns get updated in their parent's postUpdate pawn.fullUpdate(time, delta); }); } }

# Pawn

export class Pawn extends WorldcoreView { constructor(actor) { super(actor); this._actor = actor; pm.add(this); this.listen("destroyActor", this.destroy); this.init(); } init() {} get actor() {return this._actor}; destroy() { this.doomed = true; pm.delete(this); this.detach(); // Calling View clean-up. } say(event, data, throttle = 0) { if (throttle) console.warn("Only dynamic pawns can throttle 'say'!"); this.publish(this.actor.id, event, data); } listen(event, callback) { this.subscribe(this.actor.id, event, callback); } listenImmediate(event, callback) { this.subscribe(this.actor.id,{event, handling: "immediate"}, callback); } ignore(event) { this.unsubscribe(this.actor.id, event); } listenOnce(event, callback) { this.subscribe(this.actor.id, {event, handling: "oncePerFrame"}, callback); }}

Mixin.js

⇒ Mixins

// import { m4_identity } from ".."; // import { PM_Dynamic, GetPawn } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Pawn.js";

@croquet/worldcore-kernel/src unpkg

This js contains support for mixins that can be added to Views and Models. You need to define View and Model mixins slightly differently, but they otherwise use the same syntax.

import { mix, AM_Spatial, PM_Spatial } from "https://wiki.ralfbarkow.ch/assets/pages/world-core-tutorial-1/Mixins.js"

This approach is based on Justin Fagnani's post , github ⇒ Mixins and Javascript

frame.js

Import Frame Integration Promises and setup DOM helpers.

import * as frame from "https://wiki.ralfbarkow.ch/assets/v1/frame.js" const $ = (s, el=document) => el.querySelector(s) const $$ = (s, el=document) => Array.from(el.querySelectorAll(s))

.

# MyActor

Every object in Worldcore is represened by an actor/pawn pair. Spawning an actor in the model automatically instantiates a corresponding pawn in the view. The actor is replicated across all clients, while the pawn is unique to each client.

Here we define a new type of actor from the Actor base class.

//------------------------------------------------------------------------------------------ //-- MyActor ------------------------------------------------------------------------------- //------------------------------------------------------------------------------------------ class MyActor extends mix(Actor).with(AM_Spatial) { get pawn() {return MyPawn} } MyActor.register('MyActor');

Our actor is extended with the Spatial mixin, which allows it to have a position (translation/rotation/scale) in 3D space.

Every actor class should define a pawn() getter that specifies the Pawn associated with it.

Note that since actors are models, they need to be registered with Croquet after they are defined.

.

# MyPawn

Here we define our actor's pawn. The pawn is also extended with the corresponding Spatial mixin. By giving both the actor an pawn the spatial extension, the pawn will automatically track the position of the actor.

The pawn is also extended with the Visible Mixin, see `RenderManager.js `. This provides an interface to the WebGL renderer that is an optional extension to Worldcore.

# PM_Visible

export const PM_Visible = superclass => class extends superclass { destroy() { super.destroy(); const render = this.service("RenderManager"); for (const layerName in render.layers) { const layer = render.layers[layerName]; if (layer.has(this)) render.dirtyLayer(layerName); render.layers[layerName].delete(this); } } addToLayers(...names) { const render = this.service("RenderManager"); names.forEach(name => { if (!render.layers[name]) render.layers[name] = new Set(); render.layers[name].add(this); render.dirtyLayer(name); }); } removeFromLayers(...names) { const render = this.service("RenderManager"); names.forEach(name => { if (!render.layers[name]) return; render.layers[name].delete(this); if (render.layers[name].size === 0) { delete render.layers[name]; } render.dirtyLayer(name); }); } layers() { let result = []; const render = this.service("RenderManager"); for (const layerName in render.layers) { const layer = render.layers[layerName]; if (layer.has(this)) result.push(layerName); } return result; } };

# PM_WebGLVisible

export const PM_WebGLVisible = superclass => class extends PM_Visible(superclass) { constructor(...args) { super(...args); this.listen("viewGlobalChanged", this.refreshDrawTransform); } destroy() { super.destroy(); if (this.draw) this.service('WebGLRenderManager').scene.removeDrawCall(this.draw); } refreshDrawTransform() { if (this.draw) this.draw.transform.set(this.global); } setDrawCall(draw) { if (this.draw === draw) return; const scene = this.service('WebGLRenderManager').scene; if (this.draw) scene.removeDrawCall(this.draw); this.draw = draw; if (this.draw) { this.draw.transform.set(this.global || m4_identity()); scene.addDrawCall(this.draw); } } };

The method `setDrawCall()` is part of the Visible Mixin. The pawn's construtor creates a polygon mesh, builds a drawcall with it, and registers the DrawCall with the renderer. ⇒ DrawCall ⇒ Material ⇒ Texture

The Visible Mixin does all the work of managing the draw call. It will update the transform if the actor changes position, and it will remove the draw call from the renderer if the actor is destroyed.

//------------------------------------------------------------------------------------------ //-- MyPawn -------------------------------------------------------------------------------- //------------------------------------------------------------------------------------------ class MyPawn extends mix(Pawn).with(PM_Spatial, PM_WebGLVisible) { constructor(...args) { super(...args); this.mesh = Cube(1,1,1); this.mesh.load(); // Meshes need to be loaded to buffer them onto the graphics card this.mesh.clear(); // However once a mesh is loaded it can be cleared so its not taking up memory. this.setDrawCall(new DrawCall(this.mesh)); } destroy() { super.destroy(); this.mesh.destroy(); // Meshes in the WebGL renderer can be reused, so we need to explicitly destroy them when we're done with them. } }

//------------------------------------------------------------------------------------------ //-- MyModelRoot --------------------------------------------------------------------------- //------------------------------------------------------------------------------------------ // Here we define the top-level Worldcore model. This is the model that is spawned when the Croquet // session starts, and every actor and model service is contained within it. Your app can only have one // model root. // // In this case, our model root is very simple. All it does is spawn a single actor on start-up. Note that when // we create our actor, we pass in options to initialize it. our actor was extended with the Spatial mixin, so // we can pass in an initial translation and rotation. The translation is a vector in 3D space, and the // rotation is a quaternion that represents a rotation around an axis. // // Creating an actor returns a pointer to it. You can save the pointer if want to refer to the actor later, // but it's not required. The model root maintains an internal list of all actors. // // (Try modifying the init rountine to create more actors with different initialization values.) class MyModelRoot extends ModelRoot { init(...args) { super.init(...args); MyActor.create({translation: [0,0,-3], rotation: q_axisAngle(v3_normalize([1,1,1]), toRad(45))}); } } MyModelRoot.register("MyModelRoot"); //------------------------------------------------------------------------------------------ //-- MyViewRoot ---------------------------------------------------------------------------- //------------------------------------------------------------------------------------------ // Here we define the top-level Worldcore view. This is the view that is spawned when the Croquet // session starts, and every pawn and view service is contained within it. Your app can only have one // view root. // // Our view root is simple like our model root. It's extended with two view services: the input manager // and the webGL render manager. Both of these are optional. You only need the renderMananger if you're // using pawns with the Visible mixin. If your app doesn't render anything, or uses a different // renderer (like THREE.js), you can omit it. // // The InputManager catches DOM events and translates them into Croquet events you can // subscribe to. It's not absolutely required for this tutorial, but the RenderManager uses it // to respond to window resize events. class MyViewRoot extends ViewRoot { static viewServices() { return [InputManager, WebGLRenderManager]; } }

// Finally this is where we connect to our Worldcore session. This function accepts an options // object that will be passed to Croquet's session join. // // StartWorldcore() should always come at the end of your source file because it depends on your model root // and your view root. StartWorldcore({ appId: 'io.croquet.tutorial', apiKey: '1Mnk3Gf93ls03eu0Barbdzzd3xl1Ibxs7khs8Hon9', name: 'tutorial', password: 'password', model: MyModelRoot, view: MyViewRoot, });