From 744660a2628d0fe8cf90b3f09a7704eb8a84bd9e Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Thu, 9 Oct 2025 11:23:36 +0100 Subject: [PATCH 1/5] Add MeshInstance component and update Render to support mesh instances --- packages/lib/src/components/MeshInstance.tsx | 37 ++++++++++++++ packages/lib/src/components/Render.tsx | 54 +++++++++++++++----- packages/lib/src/components/index.ts | 1 + 3 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 packages/lib/src/components/MeshInstance.tsx diff --git a/packages/lib/src/components/MeshInstance.tsx b/packages/lib/src/components/MeshInstance.tsx new file mode 100644 index 00000000..2f5a1018 --- /dev/null +++ b/packages/lib/src/components/MeshInstance.tsx @@ -0,0 +1,37 @@ +import { BoxGeometry, Mesh, MeshInstance as PcMeshInstance, StandardMaterial } from "playcanvas"; +import { createComponentDefinition, getStaticNullApplication, validatePropsPartial } from "../utils/validation.ts"; +import { PublicProps } from "../utils/types-utils.ts"; +import { FC, useEffect } from "react"; +import { useMeshInstanceRegistration } from "./Render.tsx"; + + +export const MeshInstance: FC = (props) => { + const safeProps = validatePropsPartial(props, componentDefinition); + const { mesh, material } = safeProps; + const register = useMeshInstanceRegistration(); + + useEffect(() => { + if (!mesh || !material || !register) return; + const meshInstance = new PcMeshInstance(mesh, material); + register(meshInstance); + }, [mesh, material, register]); + + return null; +} + +type MeshInstanceProps = Partial>; + +const componentDefinition = createComponentDefinition( + "MeshInstance", + () => { + const app = getStaticNullApplication(); + const box = Mesh.fromGeometry(app.graphicsDevice, new BoxGeometry()); + const material = new StandardMaterial(); + const meshInstance = new PcMeshInstance(box, material); + return meshInstance; + }, + (meshInstance) => (meshInstance as PcMeshInstance).destroy(), + "MeshInstance" +) + +export default MeshInstance; \ No newline at end of file diff --git a/packages/lib/src/components/Render.tsx b/packages/lib/src/components/Render.tsx index 86f25d2c..7f3b5698 100644 --- a/packages/lib/src/components/Render.tsx +++ b/packages/lib/src/components/Render.tsx @@ -1,17 +1,40 @@ "use client" -import { FC } from "react"; +import { FC, ReactElement, useRef, createContext, useContext, useCallback } from "react"; import { useComponent } from "../hooks/index.ts"; -import { Container } from "../Container.tsx"; -import { Asset, Entity, type RenderComponent as PcRenderComponent } from "playcanvas"; +import { MeshInstance } from "./MeshInstance.tsx"; +import { Asset, Entity, MeshInstance as PcMeshInstance, type RenderComponent as PcRenderComponent } from "playcanvas"; import { PublicProps, Serializable } from "../utils/types-utils.ts"; import { getStaticNullApplication, validatePropsPartial, Schema } from "../utils/validation.ts"; import { createComponentDefinition } from "../utils/validation.ts"; +import { useParent } from "../hooks/use-parent.tsx"; + +const MeshInstanceContext = createContext<((instance: PcMeshInstance) => void) | null>(null); + +export const useMeshInstanceRegistration = () => useContext(MeshInstanceContext); const RenderComponent: FC = (props) => { - // console.log('RenderComponent', props.material.diffuse); - useComponent("render", props, componentDefinition.schema as Schema); - return null; + const { children, ...rest } = props; + + const parent : Entity = useParent(); + const meshInstancesRef = useRef([]); + + useComponent("render", rest, componentDefinition.schema as Schema); + + const registerMeshInstance = useCallback((instance: PcMeshInstance) => { + if (!meshInstancesRef.current.includes(instance)) { + meshInstancesRef.current.push(instance); + if (parent.render) { + (parent.render as PcRenderComponent).meshInstances = meshInstancesRef.current; + } + } + }, [parent]); + + return ( + + {children} + + ); } /** @@ -40,16 +63,18 @@ export const Render: FC = (props) => { if(safeProps.type === "asset" && !safeProps.asset) return null; // Render a container if the asset is a container - if (safeProps.asset?.type === 'container') { - return - { safeProps.children } - - } + // if (safeProps.asset?.type === 'container') { + // return + // { safeProps.children } + // + // } // console.log('safeProps', safeProps); // Otherwise, render the component - return } />; + return } > + { safeProps.children } + ; } @@ -66,7 +91,10 @@ interface RenderProps extends Omit>, 'ass * The asset to render. */ asset?: Asset; - children?: React.ReactNode; + /** + * A set of MeshInstance components to render. + */ + children?: ReactElement | ReactElement[]; } const componentDefinition = createComponentDefinition( diff --git a/packages/lib/src/components/index.ts b/packages/lib/src/components/index.ts index d3b8b0ac..9ba4c7f9 100644 --- a/packages/lib/src/components/index.ts +++ b/packages/lib/src/components/index.ts @@ -14,3 +14,4 @@ export { Screen } from './Screen.tsx' export { Element } from './Element.tsx' export { Gizmo } from './Gizmo.tsx' export { Environment } from './Environment.tsx' +export { MeshInstance } from './MeshInstance.tsx' From a9596f4a181b8139b550658296295b185d8dace4 Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Tue, 21 Oct 2025 14:26:01 +0100 Subject: [PATCH 2/5] feat(MeshInstance): enhance component with morphs, skins, and instancing support - Refactored MeshInstance to include support for morph weights and skin instances. - Added instancing capabilities with vertex buffer and matrix options. - Improved validation schema for props, ensuring type safety and error messaging. - Updated internal implementation to manage lifecycle and rendering more effectively. --- packages/lib/src/components/MeshInstance.tsx | 233 ++++++++++++++++--- 1 file changed, 205 insertions(+), 28 deletions(-) diff --git a/packages/lib/src/components/MeshInstance.tsx b/packages/lib/src/components/MeshInstance.tsx index 2f5a1018..a92171d7 100644 --- a/packages/lib/src/components/MeshInstance.tsx +++ b/packages/lib/src/components/MeshInstance.tsx @@ -1,37 +1,214 @@ -import { BoxGeometry, Mesh, MeshInstance as PcMeshInstance, StandardMaterial } from "playcanvas"; -import { createComponentDefinition, getStaticNullApplication, validatePropsPartial } from "../utils/validation.ts"; -import { PublicProps } from "../utils/types-utils.ts"; -import { FC, useEffect } from "react"; -import { useMeshInstanceRegistration } from "./Render.tsx"; +"use client"; +import { FC, useEffect, useRef } from "react"; +import { + MeshInstance as PcMeshInstance, + Mesh, + Material, + SkinInstance, + VertexBuffer, + MorphInstance, + RenderComponent, + Entity, + VertexFormat +} from "playcanvas"; +import { Serializable, PublicProps } from "../utils/types-utils.ts"; +import { getStaticNullApplication, validatePropsPartial } from "../utils/validation.ts"; +import { createComponentDefinition } from "../utils/validation.ts"; +import { useParent } from "../hooks/use-parent.tsx"; +import { useApp } from "../hooks/use-app.tsx"; -export const MeshInstance: FC = (props) => { - const safeProps = validatePropsPartial(props, componentDefinition); - const { mesh, material } = safeProps; - const register = useMeshInstanceRegistration(); +/** + * Declarative wrapper for pc.MeshInstance. + * Supports morphs, skins, and hardware instancing. + */ +export interface MeshInstanceProps + extends Omit>, "mesh" | "material"> { + mesh: Mesh; + material?: Material; + morphWeights?: number[]; + skinInstance?: SkinInstance; + instancing?: { + vertexBuffer?: VertexBuffer; + matrices?: Float32Array; + count?: number; + }; + visible?: boolean; +} - useEffect(() => { - if (!mesh || !material || !register) return; - const meshInstance = new PcMeshInstance(mesh, material); - register(meshInstance); - }, [mesh, material, register]); +/** + * Internal implementation + */ +const MeshInstanceComponent: FC = (props) => { + const instanceRef = useRef(null); + const parent = useParent(); + const app = useApp(); + const render = parent.render as RenderComponent | undefined; - return null; -} + if (!render) console.warn(" must be used inside a component"); + + // Create / Destroy + useEffect(() => { + if (!render) return; + + const material = props.material ?? render.material ?? null; + const mi = new PcMeshInstance(props.mesh, material, render.entity); + + // --- Morphs + if (props.mesh.morph) { + const morphInstance = new MorphInstance(props.mesh.morph); + mi.morphInstance = morphInstance; + + if (props.morphWeights) { + const count = Math.min( + props.morphWeights.length, + morphInstance.morph.targets.length + ); + for (let i = 0; i < count; i++) { + morphInstance.setWeight(i, props.morphWeights[i]); + } + } + } + + // --- Skins + if (props.skinInstance) { + mi.skinInstance = props.skinInstance; + } + + // --- Instancing + if (props.instancing) { + const { vertexBuffer, matrices, count } = props.instancing; + const device = props.mesh.device ?? app?.graphicsDevice; + + if (vertexBuffer) { + mi.setInstancing(vertexBuffer); + } else if (matrices && count && device) { + const format = VertexFormat.getDefaultInstancingFormat(device); + const vb = new VertexBuffer(device, format, count, { data: matrices }); + mi.setInstancing(vb); + (mi.instancingData as any)._destroyVertexBuffer = true; + } + + if (count) mi.instancingCount = count; + } -type MeshInstanceProps = Partial>; + mi.visible = props.visible ?? true; + render.meshInstances.push(mi); + instanceRef.current = mi; + return () => { + const idx = render.meshInstances.indexOf(mi); + if (idx !== -1) render.meshInstances.splice(idx, 1); + + // clean up any auto-created instancing buffer + if (mi.instancingData && (mi.instancingData as any)._destroyVertexBuffer) { + mi.instancingData.vertexBuffer?.destroy(); + } + + instanceRef.current = null; + }; + }, [render, props.mesh]); + + // --- Reactive updates + useEffect(() => { + const mi = instanceRef.current; + if (!mi) return; + + if (props.material) mi.material = props.material; + if (props.visible !== undefined) mi.visible = props.visible; + + // Update morph weights + if (props.morphWeights && mi.morphInstance) { + const count = Math.min( + props.morphWeights.length, + mi.morphInstance.morph.targets.length + ); + for (let i = 0; i < count; i++) { + mi.morphInstance.setWeight(i, props.morphWeights[i]); + } + } + + // Update instancing data + if (props.instancing && mi.instancingData) { + const { matrices, count } = props.instancing; + if (matrices && mi.instancingData.vertexBuffer) { + const vb = mi.instancingData.vertexBuffer; + const view = vb.lock(); + view.set(matrices); + vb.unlock(); + } + if (count) mi.instancingCount = count; + } + }, [props.material, props.visible, props.morphWeights, props.instancing]); + + return null; +}; + +/** + * Schema definition + */ const componentDefinition = createComponentDefinition( - "MeshInstance", - () => { - const app = getStaticNullApplication(); - const box = Mesh.fromGeometry(app.graphicsDevice, new BoxGeometry()); - const material = new StandardMaterial(); - const meshInstance = new PcMeshInstance(box, material); - return meshInstance; - }, - (meshInstance) => (meshInstance as PcMeshInstance).destroy(), - "MeshInstance" -) + "MeshInstance", + () => + new PcMeshInstance( + null as unknown as Mesh, + null as unknown as Material, + new Entity("mock", getStaticNullApplication()) + ), + (mi) => { + if (mi.instancingData && (mi.instancingData as any)._destroyVertexBuffer) + mi.instancingData.vertexBuffer?.destroy(); + }, + "MeshInstanceComponent" +); + +componentDefinition.schema = { + ...componentDefinition.schema, + mesh: { + validate: (v: unknown) => v instanceof Mesh, + errorMsg: (v: unknown) => `Invalid value for prop "mesh": ${v}. Expected a pc.Mesh.`, + default: undefined, + }, + material: { + validate: (v: unknown) => !v || v instanceof Material, + errorMsg: (v: unknown) => `Invalid value for prop "material": ${v}. Expected a pc.Material.`, + default: undefined, + }, + morphWeights: { + validate: (v: unknown) => !v || (Array.isArray(v) && v.every((n) => typeof n === "number")), + errorMsg: (v: unknown) => + `Invalid value for prop "morphWeights": ${v}. Expected an array of numbers.`, + default: undefined, + }, + skinInstance: { + validate: (v: unknown) => !v || v instanceof SkinInstance, + errorMsg: (v: unknown) => + `Invalid value for prop "skinInstance": ${v}. Expected a pc.SkinInstance.`, + default: undefined, + }, + instancing: { + validate: (v: unknown) => + !v || + (typeof v === "object" && + ((v as any).vertexBuffer instanceof VertexBuffer || + (v as any).matrices instanceof Float32Array)), + errorMsg: (v: unknown) => + `Invalid value for prop "instancing": ${v}. Expected { vertexBuffer?: VertexBuffer, matrices?: Float32Array, count?: number }.`, + default: undefined, + }, + visible: { + validate: (v: unknown) => v === undefined || typeof v === "boolean", + errorMsg: (v: unknown) => `Invalid value for prop "visible": ${v}. Expected a boolean.`, + default: true, + }, +}; + +/** + * Public wrapper + */ +export const MeshInstance: FC = (props) => { + const safeProps = validatePropsPartial(props, componentDefinition); + return )} />; +}; export default MeshInstance; \ No newline at end of file From 70191fbc41f6bf4317cd8f8e6dc6bf0d1433cc60 Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Tue, 21 Oct 2025 15:15:13 +0100 Subject: [PATCH 3/5] refactor(Render): simplify RenderComponent by removing mesh instance registration - Removed the mesh instance registration logic and context provider from RenderComponent. - Updated the component to directly use the props for rendering without additional state management. - Retained the container rendering logic for asset types. --- packages/lib/src/components/Render.tsx | 37 +++++++------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/packages/lib/src/components/Render.tsx b/packages/lib/src/components/Render.tsx index 7f3b5698..86057c16 100644 --- a/packages/lib/src/components/Render.tsx +++ b/packages/lib/src/components/Render.tsx @@ -1,40 +1,21 @@ "use client" -import { FC, ReactElement, useRef, createContext, useContext, useCallback } from "react"; +import { FC, ReactElement, createContext, useContext } from "react"; import { useComponent } from "../hooks/index.ts"; import { MeshInstance } from "./MeshInstance.tsx"; import { Asset, Entity, MeshInstance as PcMeshInstance, type RenderComponent as PcRenderComponent } from "playcanvas"; import { PublicProps, Serializable } from "../utils/types-utils.ts"; import { getStaticNullApplication, validatePropsPartial, Schema } from "../utils/validation.ts"; import { createComponentDefinition } from "../utils/validation.ts"; -import { useParent } from "../hooks/use-parent.tsx"; +import { Container } from "../Container.tsx"; const MeshInstanceContext = createContext<((instance: PcMeshInstance) => void) | null>(null); export const useMeshInstanceRegistration = () => useContext(MeshInstanceContext); const RenderComponent: FC = (props) => { - const { children, ...rest } = props; - - const parent : Entity = useParent(); - const meshInstancesRef = useRef([]); - - useComponent("render", rest, componentDefinition.schema as Schema); - - const registerMeshInstance = useCallback((instance: PcMeshInstance) => { - if (!meshInstancesRef.current.includes(instance)) { - meshInstancesRef.current.push(instance); - if (parent.render) { - (parent.render as PcRenderComponent).meshInstances = meshInstancesRef.current; - } - } - }, [parent]); - - return ( - - {children} - - ); + useComponent("render", props, componentDefinition.schema as Schema); + return null; } /** @@ -63,11 +44,11 @@ export const Render: FC = (props) => { if(safeProps.type === "asset" && !safeProps.asset) return null; // Render a container if the asset is a container - // if (safeProps.asset?.type === 'container') { - // return - // { safeProps.children } - // - // } + if (safeProps.asset?.type === 'container') { + return + { safeProps.children } + + } // console.log('safeProps', safeProps); From 43a8dec97f634471b1b8c92830e81b45ebbf2ae4 Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Fri, 7 Nov 2025 10:44:02 +0000 Subject: [PATCH 4/5] linting fixed --- packages/lib/src/components/MeshInstance.tsx | 40 ++++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/lib/src/components/MeshInstance.tsx b/packages/lib/src/components/MeshInstance.tsx index a92171d7..3b137d2e 100644 --- a/packages/lib/src/components/MeshInstance.tsx +++ b/packages/lib/src/components/MeshInstance.tsx @@ -18,6 +18,13 @@ import { createComponentDefinition } from "../utils/validation.ts"; import { useParent } from "../hooks/use-parent.tsx"; import { useApp } from "../hooks/use-app.tsx"; +/** + * Extended instancing data with cleanup flag + */ +type InstancingDataWithCleanup = NonNullable & { + _destroyVertexBuffer?: boolean; +}; + /** * Declarative wrapper for pc.MeshInstance. * Supports morphs, skins, and hardware instancing. @@ -84,9 +91,11 @@ const MeshInstanceComponent: FC = (props) => { mi.setInstancing(vertexBuffer); } else if (matrices && count && device) { const format = VertexFormat.getDefaultInstancingFormat(device); - const vb = new VertexBuffer(device, format, count, { data: matrices }); + const vb = new VertexBuffer(device, format, count, { data: matrices.buffer as ArrayBuffer }); mi.setInstancing(vb); - (mi.instancingData as any)._destroyVertexBuffer = true; + if (mi.instancingData) { + (mi.instancingData as InstancingDataWithCleanup)._destroyVertexBuffer = true; + } } if (count) mi.instancingCount = count; @@ -101,8 +110,9 @@ const MeshInstanceComponent: FC = (props) => { if (idx !== -1) render.meshInstances.splice(idx, 1); // clean up any auto-created instancing buffer - if (mi.instancingData && (mi.instancingData as any)._destroyVertexBuffer) { - mi.instancingData.vertexBuffer?.destroy(); + const instancingData = mi.instancingData as InstancingDataWithCleanup | null; + if (instancingData?._destroyVertexBuffer) { + instancingData.vertexBuffer?.destroy(); } instanceRef.current = null; @@ -133,7 +143,7 @@ const MeshInstanceComponent: FC = (props) => { const { matrices, count } = props.instancing; if (matrices && mi.instancingData.vertexBuffer) { const vb = mi.instancingData.vertexBuffer; - const view = vb.lock(); + const view = vb.lock() as unknown as Float32Array; view.set(matrices); vb.unlock(); } @@ -156,8 +166,10 @@ const componentDefinition = createComponentDefinition { - if (mi.instancingData && (mi.instancingData as any)._destroyVertexBuffer) - mi.instancingData.vertexBuffer?.destroy(); + const instancingData = mi.instancingData as InstancingDataWithCleanup | null; + if (instancingData?._destroyVertexBuffer) { + instancingData.vertexBuffer?.destroy(); + } }, "MeshInstanceComponent" ); @@ -187,11 +199,15 @@ componentDefinition.schema = { default: undefined, }, instancing: { - validate: (v: unknown) => - !v || - (typeof v === "object" && - ((v as any).vertexBuffer instanceof VertexBuffer || - (v as any).matrices instanceof Float32Array)), + validate: (v: unknown) => { + if (!v) return true; + if (typeof v !== "object" || v === null) return false; + const obj = v as Record; + return ( + (obj.vertexBuffer instanceof VertexBuffer) || + (obj.matrices instanceof Float32Array) + ); + }, errorMsg: (v: unknown) => `Invalid value for prop "instancing": ${v}. Expected { vertexBuffer?: VertexBuffer, matrices?: Float32Array, count?: number }.`, default: undefined, From 5c4072eb0c3a7c2cc3165ef8ae8813a9ba1945cf Mon Sep 17 00:00:00 2001 From: Mark Lundin Date: Fri, 7 Nov 2025 10:44:15 +0000 Subject: [PATCH 5/5] changeset --- .changeset/polite-words-study.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/polite-words-study.md diff --git a/.changeset/polite-words-study.md b/.changeset/polite-words-study.md new file mode 100644 index 00000000..243fc80d --- /dev/null +++ b/.changeset/polite-words-study.md @@ -0,0 +1,5 @@ +--- +"@playcanvas/react": minor +--- + +Adds a React component that wraps PlayCanvas's MeshInstance API, enabling multiple mesh instances per Render component.