From becbf14d077b8788db52b6f238681083b03587a3 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 4 May 2024 22:30:55 +0300 Subject: [PATCH 1/5] tres-renderer --- src/utils/renderers/dom/dom-api.ts | 106 +++++++++++ src/utils/renderers/tres/catalogue.ts | 8 + src/utils/renderers/tres/tres-api.ts | 265 ++++++++++++++++++++++++++ src/utils/renderers/tres/types.d.ts | 185 ++++++++++++++++++ src/utils/renderers/tres/utils.ts | 263 +++++++++++++++++++++++++ src/utils/types.d.ts | 22 +++ 6 files changed, 849 insertions(+) create mode 100644 src/utils/renderers/dom/dom-api.ts create mode 100644 src/utils/renderers/tres/catalogue.ts create mode 100644 src/utils/renderers/tres/tres-api.ts create mode 100644 src/utils/renderers/tres/types.d.ts create mode 100644 src/utils/renderers/tres/utils.ts create mode 100644 src/utils/types.d.ts diff --git a/src/utils/renderers/dom/dom-api.ts b/src/utils/renderers/dom/dom-api.ts new file mode 100644 index 00000000..08903467 --- /dev/null +++ b/src/utils/renderers/dom/dom-api.ts @@ -0,0 +1,106 @@ +import { getNodeCounter, incrementNodeCounter } from '@/utils/dom'; +import { IN_SSR_ENV, noop } from '../../shared'; +import type { Props } from '../../types'; +const FRAGMENT_TYPE = 11; // Node.DOCUMENT_FRAGMENT_NODE + +let $doc = + typeof document !== 'undefined' + ? document + : (undefined as unknown as Document); +export function setDocument(newDocument: Document) { + $doc = newDocument; +} +export function getDocument() { + return $doc; +} +export const api = { + addEventListener(node: Node, eventName: string, fn: EventListener) { + if (import.meta.env.SSR) { + return noop; + } + node.addEventListener(eventName, fn); + if (RUN_EVENT_DESTRUCTORS_FOR_SCOPED_NODES) { + return () => { + node.removeEventListener(eventName, fn); + }; + } else { + return noop; + } + }, + attr(element: HTMLElement, name: string, value: string | null) { + element.setAttribute(name, value === null ? '' : value); + }, + prop(element: HTMLElement, name: string, value: any) { + // @ts-ignore + element[name] = value; + return value; + }, + parentNode(element: Node) { + return element.parentNode; + }, + comment(text = '') { + if (IN_SSR_ENV) { + incrementNodeCounter(); + return $doc.createComment(`${text} $[${getNodeCounter()}]`); + } else { + if (IS_DEV_MODE) { + return $doc.createComment(text); + } else { + return $doc.createComment(''); + } + } + }, + text(text: string | number = '') { + return $doc.createTextNode(text as string); + }, + textContent(node: Node, text: string) { + node.textContent = text; + }, + fragment() { + return $doc.createDocumentFragment(); + }, + // @ts-expect-error + element(tagName = '', namespace?: string, ctx?: any, props?: Props): HTMLElement { + return $doc.createElement(tagName); + }, + append( + parent: HTMLElement | Node, + child: HTMLElement | Node, + // @ts-ignore + targetIndex: number = 0, + ) { + this.insert(parent, child, null); + }, + insert( + parent: HTMLElement | Node, + child: HTMLElement | Node, + anchor?: HTMLElement | Node | null, + ) { + parent.insertBefore(child, anchor || null); + }, + destroy(node: Node) { + if (IS_DEV_MODE) { + if (node === undefined) { + console.warn(`Trying to destroy undefined`); + return; + } else if (node.nodeType === FRAGMENT_TYPE) { + return; + } + const parent = node.parentNode; + if (parent !== null) { + parent.removeChild(node); + } else { + if (import.meta.env.SSR) { + console.warn(`Node is not in DOM`, node.nodeType, node.nodeName); + return; + } + throw new Error(`Node is not in DOM`); + } + } else { + if (node.nodeType === FRAGMENT_TYPE) { + return; + } + node.parentNode!.removeChild(node); + } + }, +}; diff --git a/src/utils/renderers/tres/catalogue.ts b/src/utils/renderers/tres/catalogue.ts new file mode 100644 index 00000000..0f0c512a --- /dev/null +++ b/src/utils/renderers/tres/catalogue.ts @@ -0,0 +1,8 @@ +import { cell, type Cell } from '@lifeart/gxt'; +import type { TresCatalogue } from './types' + +export const catalogue: Cell = cell({}) + +export const extend = (objects: any) => Object.assign(catalogue.value, objects) + +export default { catalogue, extend } \ No newline at end of file diff --git a/src/utils/renderers/tres/tres-api.ts b/src/utils/renderers/tres/tres-api.ts new file mode 100644 index 00000000..e84abdcb --- /dev/null +++ b/src/utils/renderers/tres/tres-api.ts @@ -0,0 +1,265 @@ +import { BufferAttribute } from 'three' +import type { Camera, Object3D } from 'three' +import { deepArrayEqual, isHTMLTag, kebabToCamel } from './utils' + +import type { TresObject, TresObject3D, TresScene } from './types' +import { catalogue } from './catalogue' +import { Props } from '@/utils/types' +import { isFn } from '@/utils/shared' + + +let scene: TresScene | null = null + +const { logError } = { + logError(msg: string) { + console.log(msg); + } +} + +const supportedPointerEvents = [ + 'onClick', + 'onPointerMove', + 'onPointerEnter', + 'onPointerLeave', +] + +export const api = { + element(tag: string, _isSVG: string, _anchor: any, _props: Props) { + let props = {}; + let args = _props[1]; + args.forEach((arg) => { + // @ts-expect-error + props[arg[0]] = arg[1]; + }); + if (!props) { props = {} } + + // @ts-expect-error + if (!props.args) { + // @ts-expect-error + props.args = [] + } + if (tag === 'template') { return null } + if (isHTMLTag(tag)) { return null } + let name = tag.replace('Tres', '') + let instance + + if (tag === 'primitive') { + // @ts-expect-error + if (props?.object === undefined) { logError('Tres primitives need a prop \'object\'') } + // @ts-expect-error + const object = props.object as TresObject + name = object.type + // @ts-expect-error + instance = Object.assign(object, { type: name, attach: props.attach, primitive: true }) + } + else { + const target = catalogue.value[name] + if (!target) { + logError(`${name} is not defined on the THREE namespace. Use extend to add it to the catalog.`) + } + // eslint-disable-next-line new-cap + // @ts-expect-error + instance = new target(...props.args) + } + + if (instance.isCamera) { + // @ts-expect-error + if (!props?.position) { + instance.position.set(3, 3, 3) + } + // @ts-expect-error + if (!props?.lookAt) { + instance.lookAt(0, 0, 0) + } + } + + // @ts-expect-error + if (props?.attach === undefined) { + if (instance.isMaterial) { instance.attach = 'material' } + else if (instance.isBufferGeometry) { instance.attach = 'geometry' } + } + + // determine whether the material was passed via prop to + // prevent it's disposal when node is removed later in it's lifecycle + + if (instance.isObject3D) { + // @ts-expect-error + if (props?.material?.isMaterial) { (instance as TresObject3D).userData.tres__materialViaProp = true } + // @ts-expect-error + if (props?.geometry?.isBufferGeometry) { (instance as TresObject3D).userData.tres__geometryViaProp = true } + } + + // Since THREE instances properties are not consistent, (Orbit Controls doesn't have a `type` property) + // we take the tag name and we save it on the userData for later use in the re-instancing process. + instance.userData = { + ...instance.userData, + tres__name: name, + } + + return instance + }, + // @ts-expect-error + append(parent, child) { + if (parent && parent.isScene) { scene = parent as unknown as TresScene } + + const parentObject = parent || scene + + if (child?.isObject3D) { + if (child?.isCamera) { + if (!scene?.userData.tres__registerCamera) { throw new Error('could not find tres__registerCamera on scene\'s userData') } + + scene?.userData.tres__registerCamera?.(child as unknown as Camera) + } + + if ( + child && supportedPointerEvents.some(eventName => child[eventName]) + ) { + if (!scene?.userData.tres__registerAtPointerEventHandler) { throw new Error('could not find tres__registerAtPointerEventHandler on scene\'s userData') } + + scene?.userData.tres__registerAtPointerEventHandler?.(child as Object3D) + } + } + + if (child?.isObject3D && parentObject?.isObject3D) { + parentObject.add(child) + child.dispatchEvent({ type: 'added' }) + } + else if (child?.isFog) { + parentObject.fog = child + } + else if (typeof child?.attach === 'string') { + child.__previousAttach = child[parentObject?.attach as string] + if (parentObject) { + parentObject[child.attach] = child + } + } + }, + // @ts-expect-error + destroy(node) { + if (!node) { return } + // remove is only called on the node being removed and not on child nodes. + + if (node.isObject3D) { + const object3D = node as unknown as Object3D + + const disposeMaterialsAndGeometries = (object3D: Object3D) => { + const tresObject3D = object3D as TresObject3D + + if (!object3D.userData.tres__materialViaProp) { + tresObject3D.material?.dispose() + tresObject3D.material = undefined + } + + if (!object3D.userData.tres__geometryViaProp) { + tresObject3D.geometry?.dispose() + tresObject3D.geometry = undefined + } + } + + const deregisterAtPointerEventHandler = scene?.userData.tres__deregisterAtPointerEventHandler + const deregisterBlockingObjectAtPointerEventHandler + = scene?.userData.tres__deregisterBlockingObjectAtPointerEventHandler + + const deregisterAtPointerEventHandlerIfRequired = (object: TresObject) => { + if (!deregisterBlockingObjectAtPointerEventHandler) { throw new Error('could not find tres__deregisterBlockingObjectAtPointerEventHandler on scene\'s userData') } + + scene?.userData.tres__deregisterBlockingObjectAtPointerEventHandler?.(object as Object3D) + + if (!deregisterAtPointerEventHandler) { throw new Error('could not find tres__deregisterAtPointerEventHandler on scene\'s userData') } + + if ( + object && supportedPointerEvents.some(eventName => object[eventName]) + ) { deregisterAtPointerEventHandler?.(object as Object3D) } + } + + const deregisterCameraIfRequired = (object: Object3D) => { + const deregisterCamera = scene?.userData.tres__deregisterCamera + + if (!deregisterCamera) { throw new Error('could not find tres__deregisterCamera on scene\'s userData') } + + if ((object as Camera).isCamera) { deregisterCamera?.(object as Camera) } + } + + node.removeFromParent?.() + object3D.traverse((child: Object3D) => { + disposeMaterialsAndGeometries(child) + deregisterCameraIfRequired(child) + deregisterAtPointerEventHandlerIfRequired?.(child as TresObject) + }) + + disposeMaterialsAndGeometries(object3D) + deregisterCameraIfRequired(object3D) + deregisterAtPointerEventHandlerIfRequired?.(object3D as TresObject) + } + + node.dispose?.() + }, + // @ts-expect-error + prop(node, prop, nextValue) { + if (node) { + let root = node + let key = prop + if (node.isObject3D && key === 'blocks-pointer-events') { + if (nextValue || nextValue === '') { scene?.userData.tres__registerBlockingObjectAtPointerEventHandler?.(node as Object3D) } + else { scene?.userData.tres__deregisterBlockingObjectAtPointerEventHandler?.(node as Object3D) } + + return + } + + let finalKey = kebabToCamel(key) + let target = root?.[finalKey] + + if (key === 'args') { + const prevNode = node as TresObject3D + const prevArgs: any[] = []; + const args = nextValue ?? [] + const instanceName = node.userData.tres__name || node.type + + if (instanceName && prevArgs.length && !deepArrayEqual(prevArgs, args)) { + root = Object.assign(prevNode, new catalogue.value[instanceName](...nextValue)) + } + return + } + + if (root.type === 'BufferGeometry') { + if (key === 'args') { return } + root.setAttribute( + kebabToCamel(key), + new BufferAttribute(...(nextValue as ConstructorParameters)), + ) + return + } + + // Traverse pierced props (e.g. foo-bar=value => foo.bar = value) + if (key.includes('-') && target === undefined) { + const chain = key.split('-') + // @ts-expect-error + target = chain.reduce((acc, key) => acc[kebabToCamel(key)], root) + key = chain.pop() as string + finalKey = key.toLowerCase() + // @ts-expect-error + if (!target?.set) { root = chain.reduce((acc, key) => acc[kebabToCamel(key)], root) } + } + let value = nextValue + if (value === '') { value = true } + // Set prop, prefer atomic methods if applicable + if (isFn(target)) { + // don't call pointer event callback functions + if (!supportedPointerEvents.includes(prop)) { + if (Array.isArray(value)) { node[finalKey](...value) } + else { node[finalKey](value) } + } + return + } + if (!target?.set && !isFn(target)) { root[finalKey] = value } + else if (target.constructor === value.constructor && target?.copy) { target?.copy(value) } + else if (Array.isArray(value)) { target.set(...value) } + else if (!target.isColor && target.setScalar) { target.setScalar(value) } + else { target.set(value) } + } + }, + // @ts-expect-error + parentNode(node) { + return node?.parent || null + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/types.d.ts b/src/utils/renderers/tres/types.d.ts new file mode 100644 index 00000000..f977eb4f --- /dev/null +++ b/src/utils/renderers/tres/types.d.ts @@ -0,0 +1,185 @@ +import type * as THREE from 'three' +// import type { EventProps as PointerEventHandlerEventProps } from '../composables/usePointerEventHandler' + +// Based on React Three Fiber types by Pmndrs +// https://github.com/pmndrs/react-three-fiber/blob/v9/packages/fiber/src/three-types.ts + +export type AttachFnType = (parent: any, self: O) => () => void +export type AttachType = string | AttachFnType + +export type ConstructorRepresentation = new (...args: any[]) => any +export type NonFunctionKeys

= { [K in keyof P]-?: P[K] extends Function ? never : K }[keyof P] +export type Overwrite = Omit> & O +export type Properties = Pick> +export type Mutable

= { [K in keyof P]: P[K] | Readonly } +export type Args = T extends ConstructorRepresentation ? ConstructorParameters : any[] + +export interface TresCatalogue { + [name: string]: ConstructorRepresentation +} +export type TresCamera = THREE.OrthographicCamera | THREE.PerspectiveCamera + +export interface InstanceProps { + args?: Args

+ object?: T + visible?: boolean + dispose?: null + attach?: AttachType +} + +interface TresBaseObject { + attach?: string + removeFromParent?: () => void + dispose?: () => void + [prop: string]: any // for arbitrary properties +} + +// Custom type for geometry and material properties in Object3D +export interface TresObject3D extends THREE.Object3D { + geometry?: THREE.BufferGeometry & TresBaseObject + material?: THREE.Material & TresBaseObject + userData: { + tres__materialViaProp: boolean + tres__geometryViaProp: boolean + [key: string]: any + } +} + +export type TresObject = TresBaseObject & (TresObject3D | THREE.BufferGeometry | THREE.Material | THREE.Fog) + +export interface TresScene extends THREE.Scene { + userData: { + // keys are prefixed with tres__ to avoid name collisions + tres__registerCamera?: (newCamera: THREE.Camera, active?: boolean) => void + tres__deregisterCamera?: (camera: THREE.Camera) => void + tres__registerAtPointerEventHandler?: (object: THREE.Object3D & PointerEventHandlerEventProps) => void + tres__deregisterAtPointerEventHandler?: (object: THREE.Object3D) => void + tres__registerBlockingObjectAtPointerEventHandler?: (object: THREE.Object3D) => void + tres__deregisterBlockingObjectAtPointerEventHandler?: (object: THREE.Object3D) => void + [key: string]: any + } +} + +// Events + +export interface Intersection extends THREE.Intersection { + /** The event source (the object which registered the handler) */ + eventObject: TresObject +} + +export interface IntersectionEvent extends Intersection { + /** The event source (the object which registered the handler) */ + eventObject: TresObject + /** An array of intersections */ + intersections: Intersection[] + /** vec3.set(pointer.x, pointer.y, 0).unproject(camera) */ + unprojectedPoint: THREE.Vector3 + /** Normalized event coordinates */ + pointer: THREE.Vector2 + /** Delta between first click and this event */ + delta: number + /** The ray that pierced it */ + ray: THREE.Ray + /** The camera that was used by the raycaster */ + camera: TresCamera + /** stopPropagation will stop underlying handlers from firing */ + stopPropagation: () => void + /** The original host event */ + nativeEvent: TSourceEvent + /** If the event was stopped by calling stopPropagation */ + stopped: boolean +} + +export type ThreeEvent = IntersectionEvent & Properties +export type DomEvent = PointerEvent | MouseEvent | WheelEvent + +export interface Events { + onClick: EventListener + onContextMenu: EventListener + onDoubleClick: EventListener + onWheel: EventListener + onPointerDown: EventListener + onPointerUp: EventListener + onPointerLeave: EventListener + onPointerMove: EventListener + onPointerCancel: EventListener + onLostPointerCapture: EventListener +} + +export interface EventHandlers { + onClick?: (event: ThreeEvent) => void + onContextMenu?: (event: ThreeEvent) => void + onDoubleClick?: (event: ThreeEvent) => void + onPointerUp?: (event: ThreeEvent) => void + onPointerDown?: (event: ThreeEvent) => void + onPointerOver?: (event: ThreeEvent) => void + onPointerOut?: (event: ThreeEvent) => void + onPointerEnter?: (event: ThreeEvent) => void + onPointerLeave?: (event: ThreeEvent) => void + onPointerMove?: (event: ThreeEvent) => void + onPointerMissed?: (event: MouseEvent) => void + onPointerCancel?: (event: ThreeEvent) => void + onWheel?: (event: ThreeEvent) => void +} + +interface MathRepresentation { + set: (...args: number[] | [THREE.ColorRepresentation]) => any +} +interface VectorRepresentation extends MathRepresentation { + setScalar: (s: number) => any +} + +export interface VectorCoordinates { + x: number + y: number + z: number +} + +export type MathType = T extends THREE.Color + ? ConstructorParameters | THREE.ColorRepresentation + + : T extends VectorRepresentation | THREE.Layers | THREE.Euler ? T | Parameters | number | VectorCoordinates : T | Parameters + +export type TresVector2 = MathType +export type TresVector3 = MathType +export type TresVector4 = MathType +export type TresColor = MathType +export type TresLayers = MathType +export type TresQuaternion = MathType +export type TresEuler = MathType + +type WithMathProps

= { [K in keyof P]: P[K] extends MathRepresentation | THREE.Euler ? MathType : P[K] } + +interface RaycastableRepresentation { + raycast: (raycaster: THREE.Raycaster, intersects: THREE.Intersection[]) => void +} +type EventProps

= P extends RaycastableRepresentation ? Partial : unknown + +export interface VueProps

{ + children?: VNode

[] + ref?: VNodeRef + key?: string | number | symbol +} + +type ElementProps> = Partial< + Overwrite, VueProps

& EventProps

> +> + +export type ThreeElement = Mutable< + Overwrite, Omit, T>, 'object'>> +> + +type ThreeExports = typeof THREE +type ThreeInstancesImpl = { + [K in keyof ThreeExports as Uncapitalize]: ThreeExports[K] extends ConstructorRepresentation + ? ThreeElement + : never +} + +export interface ThreeInstances extends ThreeInstancesImpl { + primitive: Omit, 'args'> & { object: object } +} + +type TresComponents = { + [K in keyof ThreeInstances as `Tres${Capitalize}`]: DefineComponent +} diff --git a/src/utils/renderers/tres/utils.ts b/src/utils/renderers/tres/utils.ts new file mode 100644 index 00000000..7bbfe5a9 --- /dev/null +++ b/src/utils/renderers/tres/utils.ts @@ -0,0 +1,263 @@ +import { DoubleSide, MeshBasicMaterial, Vector3 } from 'three' +import type { Mesh, Object3D, Scene } from 'three' + +export function toSetMethodName(key: string) { + return `set${key[0].toUpperCase()}${key.slice(1)}` +} + +export const merge = (target: any, source: any) => { + // Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties + for (const key of Object.keys(source)) { + if (source[key] instanceof Object) { + Object.assign(source[key], merge(target[key], source[key])) + } + } + + // Join `target` and modified `source` + Object.assign(target || {}, source) + return target +} + +const HTML_TAGS + = 'html,body,base,head,link,meta,style,title,address,article,aside,footer,' + + 'header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' + + 'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' + + 'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' + + 'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' + + 'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' + + 'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' + + 'option,output,progress,select,textarea,details,dialog,menu,' + + 'summary,template,blockquote,iframe,tfoot' + +export const isHTMLTag = /* #__PURE__ */ makeMap(HTML_TAGS) + +export function isDOMElement(obj: any): obj is HTMLElement { + return obj && obj.nodeType === 1 +} + +export function kebabToCamel(str: string) { + return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) +} + +export function makeMap(str: string, expectsLowerCase?: boolean): (key: string) => boolean { + const map: Record = Object.create(null) + const list: Array = str.split(',') + for (let i = 0; i < list.length; i++) { + map[list[i]] = true + } + return expectsLowerCase ? val => !!map[val.toLowerCase()] : val => !!map[val] +} + +export const uniqueBy = (array: T[], iteratee: (value: T) => K): T[] => { + const seen = new Set() + const result: T[] = [] + + for (const item of array) { + const identifier = iteratee(item) + if (!seen.has(identifier)) { + seen.add(identifier) + result.push(item) + } + } + + return result +} + +export const get = (obj: any, path: string | string[]): T | undefined => { + if (!path) { + return undefined + } + + // Regex explained: https://regexr.com/58j0k + const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g) + + return pathArray?.reduce((prevObj, key) => prevObj && prevObj[key], obj) +} + +export const set = (obj: any, path: string | string[], value: any): void => { + // Regex explained: https://regexr.com/58j0k + const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g) + + if (pathArray) { + pathArray.reduce((acc, key, i) => { + if (acc[key] === undefined) { + acc[key] = {} + } + if (i === pathArray.length - 1) { + acc[key] = value + } + return acc[key] + }, obj) + } +} + +export function deepEqual(a: any, b: any): boolean { + if (isDOMElement(a) && isDOMElement(b)) { + const attrsA = a.attributes + const attrsB = b.attributes + + if (attrsA.length !== attrsB.length) { + return false + } + + return Array.from(attrsA).every(({ name, value }) => b.getAttribute(name) === value) + } + // If both are primitives, return true if they are equal + if (a === b) { + return true + } + + // If either of them is null or not an object, return false + if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') { + return false + } + + // Get the keys of both objects + const keysA = Object.keys(a); const keysB = Object.keys(b) + + // If they have different number of keys, they are not equal + if (keysA.length !== keysB.length) { + return false + } + + // Check each key in A to see if it exists in B and its value is the same in both + for (const key of keysA) { + if (!keysB.includes(key) || !deepEqual(a[key], b[key])) { + return false + } + } + + return true +} + +export function deepArrayEqual(arr1: any[], arr2: any[]): boolean { + // If they're not both arrays, return false + if (!Array.isArray(arr1) || !Array.isArray(arr2)) { + return false + } + + // If they don't have the same length, they're not equal + if (arr1.length !== arr2.length) { + return false + } + + // Check each element of arr1 against the corresponding element of arr2 + for (let i = 0; i < arr1.length; i++) { + if (!deepEqual(arr1[i], arr2[i])) { + return false + } + } + + return true +} + +/** + * TypeSafe version of Array.isArray + */ +export const isArray = Array.isArray as (a: any) => a is any[] | readonly any[] + +export function editSceneObject(scene: Scene, objectUuid: string, propertyPath: string[], value: any): void { + // Function to recursively find the object by UUID + const findObjectByUuid = (node: Object3D): Object3D | undefined => { + if (node.uuid === objectUuid) { + return node + } + + for (const child of node.children) { + const found = findObjectByUuid(child) + if (found) { + return found + } + } + + return undefined + } + + // Find the target object + const targetObject = findObjectByUuid(scene) + if (!targetObject) { + console.warn('Object with UUID not found in the scene.') + return + } + + // Traverse the property path to get to the desired property + let currentProperty: any = targetObject + for (let i = 0; i < propertyPath.length - 1; i++) { + if (currentProperty[propertyPath[i]] !== undefined) { + currentProperty = currentProperty[propertyPath[i]] + } + else { + console.warn(`Property path is not valid: ${propertyPath.join('.')}`) + return + } + } + + // Set the new value + const lastProperty = propertyPath[propertyPath.length - 1] + if (currentProperty[lastProperty] !== undefined) { + currentProperty[lastProperty] = value + } + else { + console.warn(`Property path is not valid: ${propertyPath.join('.')}`) + } +} + +export function createHighlightMaterial(): MeshBasicMaterial { + return new MeshBasicMaterial({ + color: 0xA7E6D7, // Highlight color, e.g., yellow + transparent: true, + opacity: 0.2, + depthTest: false, // So the highlight is always visible + side: DoubleSide, // To ensure the highlight is visible from all angles + }) +} +let animationFrameId: number | null = null +export function animateHighlight(highlightMesh: Mesh, startTime: number): void { + const currentTime = Date.now() + const time = (currentTime - startTime) / 1000 // convert to seconds + + // Pulsing effect parameters + const scaleAmplitude = 0.07 // Amplitude of the scale pulsation + const pulseSpeed = 2.5 // Speed of the pulsation + + // Calculate the scale factor with a sine function for pulsing effect + const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time) + + // Apply the scale factor + highlightMesh.scale.set(scaleFactor, scaleFactor, scaleFactor) + + // Update the animation frame ID + animationFrameId = requestAnimationFrame(() => animateHighlight(highlightMesh, startTime)) +} + +export function stopHighlightAnimation(): void { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } +} + +export function createHighlightMesh(object: Object3D): Mesh { + const highlightMaterial = new MeshBasicMaterial({ + color: 0xA7E6D7, // Highlight color, e.g., yellow + transparent: true, + opacity: 0.2, + depthTest: false, // So the highlight is always visible + side: DoubleSide, // To e + }) + // Clone the geometry of the object. You might need a more complex approach + // if the object's geometry is not straightforward. + // @ts-expect-error + const highlightMesh = new HightlightMesh(object.geometry.clone(), highlightMaterial) + + return highlightMesh +} + +export function extractBindingPosition(binding: any): Vector3 { + let observer = binding.value + if (binding.value && binding.value?.isMesh) { + observer = binding.value.position + } + if (Array.isArray(binding.value)) { observer = new Vector3(...observer) } + return observer +} \ No newline at end of file diff --git a/src/utils/types.d.ts b/src/utils/types.d.ts new file mode 100644 index 00000000..d31b767a --- /dev/null +++ b/src/utils/types.d.ts @@ -0,0 +1,22 @@ +export type RenderableType = Node | ComponentReturnType | string | number; +export type ShadowRootMode = 'open' | 'closed' | null; +export type ModifierFn = ( + element: HTMLElement, + ...args: unknown[] +) => void | DestructorFn; + +export type Attr = + | MergedCell + | Cell + | string + | ((element: HTMLElement, attribute: string) => void); + +export type TagAttr = [string, Attr]; +export type TagProp = [string, Attr]; +export type TagEvent = [string, EventListener | ModifierFn]; +export type FwType = [TagProp[], TagAttr[], TagEvent[]]; +export type Props = [TagProp[], TagAttr[], TagEvent[], FwType?]; + +export type Fn = () => unknown; +export type InElementFnArg = () => HTMLElement; +export type BranchCb = () => ComponentReturnType | Node; From 7a2cb04b24573acd22f620e6d18566e6ae550972 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 Jan 2025 16:58:58 +0300 Subject: [PATCH 2/5] + --- package.json | 14 +- pnpm-lock.yaml | 45 +++ src/components/pages/PageOne.gts | 12 +- src/utils/component.ts | 3 +- src/utils/dom-api.ts | 6 +- src/utils/renderers/tres/TresCanvas.ts | 43 +++ src/utils/renderers/tres/catalogue.ts | 5 +- src/utils/renderers/tres/index.ts | 13 + src/utils/renderers/tres/tres-api.ts | 19 +- .../renderers/tres/useTresContextProvider.ts | 296 ++++++++++++++++++ 10 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 src/utils/renderers/tres/TresCanvas.ts create mode 100644 src/utils/renderers/tres/index.ts create mode 100644 src/utils/renderers/tres/useTresContextProvider.ts diff --git a/package.json b/package.json index 83e1829a..afa4bcc8 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "@types/qunit": "^2.19.9", "autoprefixer": "^10.4.16", "backburner.js": "^2.8.0", + "express": "^4.18.2", + "glint-environment-gxt": "file:./glint-environment-gxt", + "happy-dom": "14.10.1", "nyc": "^15.1.0", "postcss": "^8.4.33", "prettier": "^3.1.1", @@ -93,16 +96,15 @@ "vite-plugin-circular-dependency": "^0.2.1", "vite-plugin-dts": "^3.7.0", "vitest": "^1.1.1", - "zx": "^7.2.3", - "express": "^4.18.2", - "happy-dom": "14.10.1", - "glint-environment-gxt": "file:./glint-environment-gxt" + "zx": "^7.2.3" }, "dependencies": { "@babel/core": "^7.23.6", - "decorator-transforms": "2.0.0", "@babel/preset-typescript": "^7.23.3", "@glimmer/syntax": "^0.87.1", - "content-tag": "2.0.1" + "@types/three": "^0.172.0", + "content-tag": "2.0.1", + "decorator-transforms": "2.0.0", + "three": "^0.172.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a0e59bd..a4987bbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,18 @@ dependencies: '@glimmer/syntax': specifier: ^0.87.1 version: 0.87.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 content-tag: specifier: 2.0.1 version: 2.0.1 decorator-transforms: specifier: 2.0.0 version: 2.0.0(@babel/core@7.23.6) + three: + specifier: ^0.172.0 + version: 0.172.0 devDependencies: '@glint/core': @@ -2426,6 +2432,10 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true + /@tweenjs/tween.js@23.1.3: + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + dev: false + /@types/argparse@1.0.38: resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} dev: true @@ -2542,10 +2552,29 @@ packages: '@types/node': 20.10.5 dev: true + /@types/stats.js@0.17.3: + resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==} + dev: false + /@types/symlink-or-copy@1.2.2: resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==} dev: true + /@types/three@0.172.0: + resolution: {integrity: sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==} + dependencies: + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.3 + '@types/webxr': 0.5.21 + '@webgpu/types': 0.1.53 + fflate: 0.8.2 + meshoptimizer: 0.18.1 + dev: false + + /@types/webxr@0.5.21: + resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==} + dev: false + /@types/which@3.0.3: resolution: {integrity: sha512-2C1+XoY0huExTbs8MQv1DuS5FS86+SEjdM9F/+GS61gg5Hqbtj8ZiDSx8MfWcyei907fIPbfPGCOrNUTnVHY1g==} dev: true @@ -2648,6 +2677,10 @@ packages: resolution: {integrity: sha512-6XptuzlMvN4l4cDnDw36pdGEV+9njYkQ1ZE0Q6iZLwrKefKaOJyiFmcP3/KBDHbt72cJZGtllAc1GaHe6XGAyg==} dev: true + /@webgpu/types@0.1.53: + resolution: {integrity: sha512-x+BLw/opaz9LiVyrMsP75nO1Rg0QfrACUYIbVSfGwY/w0DiWIPYYrpte6us//KZXinxFAOJl0+C17L1Vi2vmDw==} + dev: false + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -4399,6 +4432,10 @@ packages: web-streams-polyfill: 3.3.2 dev: true + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + dev: false + /figures@1.7.0: resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} engines: {node: '>=0.10.0'} @@ -5780,6 +5817,10 @@ packages: engines: {node: '>= 8'} dev: true + /meshoptimizer@0.18.1: + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + dev: false + /methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -7393,6 +7434,10 @@ packages: any-promise: 1.3.0 dev: true + /three@0.172.0: + resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==} + dev: false + /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true diff --git a/src/components/pages/PageOne.gts b/src/components/pages/PageOne.gts index e7dd3da2..1270780e 100644 --- a/src/components/pages/PageOne.gts +++ b/src/components/pages/PageOne.gts @@ -1,6 +1,8 @@ -import { Component, cell } from '@lifeart/gxt'; +import { cell } from '@lifeart/gxt'; import { Smile } from './page-one/Smile'; import { Table } from './page-one/Table.gts'; +import { TresProvider } from '@/utils/renderers/tres'; +import { TresCanvas } from '@/utils/renderers/tres/TresCanvas'; function Controls() { const color = cell('red'); @@ -28,7 +30,13 @@ export function PageOne() {


- + + + + + + +
Imagine a world where the robust, mature ecosystems of development tools meet the cutting-edge performance of modern compilers. That's what we're building here! Our platform takes the best of established diff --git a/src/utils/component.ts b/src/utils/component.ts index 060cac2d..c4f74dcf 100644 --- a/src/utils/component.ts +++ b/src/utils/component.ts @@ -100,7 +100,8 @@ export function renderElement( api.textContent(node, String(value ?? '')); })); } else { - throw new Error(`Unknown element type ${el}`); + api.insert(target, el, placeholder); + // throw new Error(`Unknown element type ${el}`); } } else { for (let i = 0; i < el.length; i++) { diff --git a/src/utils/dom-api.ts b/src/utils/dom-api.ts index 27660cee..8130af4c 100644 --- a/src/utils/dom-api.ts +++ b/src/utils/dom-api.ts @@ -83,6 +83,10 @@ export class HTMLBrowserDOMApi implements DOMApi { child: HTMLElement | Node, anchor?: HTMLElement | Node | null, ) { - parent.insertBefore(child, anchor || null); + try { + parent.insertBefore(child, anchor || null); + } catch(e) { + debugger; + } } } diff --git a/src/utils/renderers/tres/TresCanvas.ts b/src/utils/renderers/tres/TresCanvas.ts new file mode 100644 index 00000000..874d9ee3 --- /dev/null +++ b/src/utils/renderers/tres/TresCanvas.ts @@ -0,0 +1,43 @@ +import { $_tag, $_fin, Component, $_GET_ARGS, $_GET_SLOTS, $_slot } from '@lifeart/gxt'; +import { getContext, provideContext, RENDERING_CONTEXT, ROOT_CONTEXT } from '@/utils/context'; +import { TresBrowserDOMApi } from './tres-api'; + +// + +export function TresCanvas(this: Component) { + $_GET_ARGS(this, arguments); + const canvasNode = $_tag('canvas', [[],[],[]], [], this) as HTMLCanvasElement; + canvasNode.setAttribute('data-tres', 'tresjs 0.0.0'); + canvasNode.style.display = 'block'; + canvasNode.style.border = '1px solid red'; + canvasNode.style.width = '100%'; + canvasNode.style.height = '100%'; + canvasNode.style.position = 'relative'; + canvasNode.style.top = '0'; + canvasNode.style.left = '0'; + canvasNode.style.pointerEvents = 'auto'; + + const $slots = $_GET_SLOTS(this, arguments); + + const api = new TresBrowserDOMApi(); + const self = this; + provideContext(this, RENDERING_CONTEXT, api); + // @ts-expect-error + return $_fin([canvasNode, $_slot("default", () => [canvasNode], $slots, self)], this); +} diff --git a/src/utils/renderers/tres/catalogue.ts b/src/utils/renderers/tres/catalogue.ts index 0f0c512a..623f433d 100644 --- a/src/utils/renderers/tres/catalogue.ts +++ b/src/utils/renderers/tres/catalogue.ts @@ -1,7 +1,10 @@ import { cell, type Cell } from '@lifeart/gxt'; import type { TresCatalogue } from './types' +import * as THREE from 'three'; -export const catalogue: Cell = cell({}) + +// @ts-expect-error catalogue type +export const catalogue: Cell = cell(THREE); export const extend = (objects: any) => Object.assign(catalogue.value, objects) diff --git a/src/utils/renderers/tres/index.ts b/src/utils/renderers/tres/index.ts new file mode 100644 index 00000000..b42d2bc7 --- /dev/null +++ b/src/utils/renderers/tres/index.ts @@ -0,0 +1,13 @@ +import { $_GET_ARGS, hbs, type Root } from '@lifeart/gxt'; +import { getContext, provideContext, RENDERING_CONTEXT, ROOT_CONTEXT } from '@/utils/context'; +import { TresBrowserDOMApi } from './tres-api'; + +export function TresProvider() { + // @ts-expect-error typings error + $_GET_ARGS(this, arguments); + // @ts-expect-error typings error + const root = getContext(this, ROOT_CONTEXT)!; + // @ts-expect-error typings error + provideContext(this, RENDERING_CONTEXT, new TresBrowserDOMApi()); + return hbs`{{yield}}`; +} diff --git a/src/utils/renderers/tres/tres-api.ts b/src/utils/renderers/tres/tres-api.ts index e84abdcb..f6e15226 100644 --- a/src/utils/renderers/tres/tres-api.ts +++ b/src/utils/renderers/tres/tres-api.ts @@ -6,6 +6,7 @@ import type { TresObject, TresObject3D, TresScene } from './types' import { catalogue } from './catalogue' import { Props } from '@/utils/types' import { isFn } from '@/utils/shared' +import { DOMApi } from '@/utils/dom-api' let scene: TresScene | null = null @@ -23,8 +24,8 @@ const supportedPointerEvents = [ 'onPointerLeave', ] -export const api = { - element(tag: string, _isSVG: string, _anchor: any, _props: Props) { +export class TresBrowserDOMApi implements DOMApi { + element(tag: string, _isSVG = false, _anchor = false, _props: Props = [[],[],[]]) { let props = {}; let args = _props[1]; args.forEach((arg) => { @@ -97,9 +98,9 @@ export const api = { } return instance - }, + } // @ts-expect-error - append(parent, child) { + insert(parent, child) { if (parent && parent.isScene) { scene = parent as unknown as TresScene } const parentObject = parent || scene @@ -133,7 +134,7 @@ export const api = { parentObject[child.attach] = child } } - }, + } // @ts-expect-error destroy(node) { if (!node) { return } @@ -193,7 +194,11 @@ export const api = { } node.dispose?.() - }, + } + // @ts-expect-error + attr(node, prop, nextValue) { + this.prop(node, prop, nextValue); + } // @ts-expect-error prop(node, prop, nextValue) { if (node) { @@ -257,7 +262,7 @@ export const api = { else if (!target.isColor && target.setScalar) { target.setScalar(value) } else { target.set(value) } } - }, + } // @ts-expect-error parentNode(node) { return node?.parent || null diff --git a/src/utils/renderers/tres/useTresContextProvider.ts b/src/utils/renderers/tres/useTresContextProvider.ts new file mode 100644 index 00000000..283fb9c6 --- /dev/null +++ b/src/utils/renderers/tres/useTresContextProvider.ts @@ -0,0 +1,296 @@ +import type { Camera, WebGLRenderer } from 'three' +import type { ComputedRef, DeepReadonly, MaybeRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue' +import type { RendererLoop } from '../../core/loop' +import type { EmitEventFn, TresControl, TresObject, TresScene } from '../../types' +import type { UseRendererOptions } from '../useRenderer' +import { useFps, useMemory, useRafFn } from '@vueuse/core' +import { Raycaster } from 'three' +import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef } from 'vue' +import { extend } from '../../core/catalogue' +import { createRenderLoop } from '../../core/loop' +import { calculateMemoryUsage } from '../../utils/perf' + +import { useCamera } from '../useCamera' +import { useRenderer } from '../useRenderer' +import useSizes, { type SizesType } from '../useSizes' +import { type TresEventManager, useTresEventManager } from '../useTresEventManager' +import { useTresReady } from '../useTresReady' + +export interface InternalState { + priority: Ref + frames: Ref + maxFrames: number +} + +export interface RenderState { + /** + * If set to 'on-demand', the scene will only be rendered when the current frame is invalidated + * If set to 'manual', the scene will only be rendered when advance() is called + * If set to 'always', the scene will be rendered every frame + */ + mode: Ref<'always' | 'on-demand' | 'manual'> + priority: Ref + frames: Ref + maxFrames: number + canBeInvalidated: ComputedRef +} + +export interface PerformanceState { + maxFrames: number + fps: { + value: number + accumulator: number[] + } + memory: { + currentMem: number + allocatedMem: number + accumulator: number[] + } +} + +export interface TresContext { + scene: ShallowRef + sizes: SizesType + extend: (objects: any) => void + camera: ComputedRef + cameras: DeepReadonly> + controls: Ref + renderer: ShallowRef + raycaster: ShallowRef + perf: PerformanceState + render: RenderState + // Loop + loop: RendererLoop + /** + * Invalidates the current frame when renderMode === 'on-demand' + */ + invalidate: () => void + /** + * Advance one frame when renderMode === 'manual' + */ + advance: () => void + // Camera + registerCamera: (maybeCamera: unknown) => void + setCameraActive: (cameraOrUuid: Camera | string) => void + deregisterCamera: (maybeCamera: unknown) => void + eventManager?: TresEventManager + // Events + // Temporaly add the methods to the context, this should be handled later by the EventManager state on the context https://github.com/Tresjs/tres/issues/515 + // When thats done maybe we can short the names of the methods since the parent will give the context. + registerObjectAtPointerEventHandler?: (object: TresObject) => void + deregisterObjectAtPointerEventHandler?: (object: TresObject) => void + registerBlockingObjectAtPointerEventHandler?: (object: TresObject) => void + deregisterBlockingObjectAtPointerEventHandler?: (object: TresObject) => void +} + +export function useTresContextProvider({ + scene, + canvas, + windowSize, + rendererOptions, + emit, +}: { + scene: TresScene + canvas: MaybeRef + windowSize: MaybeRefOrGetter + rendererOptions: UseRendererOptions + emit: EmitEventFn + +}): TresContext { + const localScene = shallowRef(scene) + const sizes = useSizes(windowSize, canvas) + + const { + camera, + cameras, + registerCamera, + deregisterCamera, + setCameraActive, + } = useCamera({ sizes, scene }) + + // Render state + + const render: RenderState = { + mode: ref(rendererOptions.renderMode || 'always') as Ref<'always' | 'on-demand' | 'manual'>, + priority: ref(0), + frames: ref(0), + maxFrames: 60, + canBeInvalidated: computed(() => render.mode.value === 'on-demand' && render.frames.value === 0), + } + + function invalidate(frames = 1) { + // Increase the frame count, ensuring not to exceed a maximum if desired + if (rendererOptions.renderMode === 'on-demand') { + render.frames.value = Math.min(render.maxFrames, render.frames.value + frames) + } + } + + function advance() { + if (rendererOptions.renderMode === 'manual') { + render.frames.value = 1 + } + } + + const { renderer } = useRenderer( + { + scene, + canvas, + options: rendererOptions, + emit, + // TODO: replace contextParts with full ctx at https://github.com/Tresjs/tres/issues/516 + contextParts: { sizes, camera, render, invalidate, advance }, + }, + ) + + const ctx: TresContext = { + sizes, + scene: localScene, + camera, + cameras: readonly(cameras), + renderer, + raycaster: shallowRef(new Raycaster()), + controls: ref(null), + perf: { + maxFrames: 160, + fps: { + value: 0, + accumulator: [], + }, + memory: { + currentMem: 0, + allocatedMem: 0, + accumulator: [], + }, + }, + render, + advance, + extend, + invalidate, + registerCamera, + setCameraActive, + deregisterCamera, + loop: createRenderLoop(), + } + + provide('useTres', ctx) + + // Add context to scene local state + ctx.scene.value.__tres = { + root: ctx, + } + + // The loop + + ctx.loop.register(() => { + if (camera.value && render.frames.value > 0) { + renderer.value.render(scene, camera.value) + emit('render', ctx.renderer.value) + } + + // Reset priority + render.priority.value = 0 + + if (render.mode.value === 'always') { + render.frames.value = 1 + } + else { + render.frames.value = Math.max(0, render.frames.value - 1) + } + }, 'render') + + const { on: onTresReady, cancel: cancelTresReady } = useTresReady(ctx)! + + ctx.loop.setReady(false) + ctx.loop.start() + + onTresReady(() => { + emit('ready', ctx) + ctx.loop.setReady(true) + useTresEventManager(scene, ctx, emit) + }) + + onUnmounted(() => { + cancelTresReady() + ctx.loop.stop() + }) + + // Performance + const updateInterval = 100 // Update interval in milliseconds + const fps = useFps({ every: updateInterval }) + const { isSupported, memory } = useMemory({ interval: updateInterval }) + const maxFrames = 160 + let lastUpdateTime = performance.now() + + const updatePerformanceData = ({ timestamp }: { timestamp: number }) => { + // Update WebGL Memory Usage (Placeholder for actual logic) + // perf.memory.value = calculateMemoryUsage(gl) + if (ctx.scene.value) { + ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject) + } + + // Update memory usage + if (timestamp - lastUpdateTime >= updateInterval) { + lastUpdateTime = timestamp + + // Update FPS + ctx.perf.fps.accumulator.push(fps.value as never) + + if (ctx.perf.fps.accumulator.length > maxFrames) { + ctx.perf.fps.accumulator.shift() + } + + ctx.perf.fps.value = fps.value + + // Update memory + if (isSupported.value && memory.value) { + ctx.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never) + + if (ctx.perf.memory.accumulator.length > maxFrames) { + ctx.perf.memory.accumulator.shift() + } + + ctx.perf.memory.currentMem + = ctx.perf.memory.accumulator.reduce((a, b) => a + b, 0) / ctx.perf.memory.accumulator.length + } + } + } + + // Devtools + let accumulatedTime = 0 + const interval = 1 // Interval in milliseconds, e.g., 1000 ms = 1 second + + const { pause } = useRafFn(({ delta }) => { + if (!window.__TRES__DEVTOOLS__) { return } + + updatePerformanceData({ timestamp: performance.now() }) + + // Accumulate the delta time + accumulatedTime += delta + + // Check if the accumulated time is greater than or equal to the interval + if (accumulatedTime >= interval) { + window.__TRES__DEVTOOLS__.cb(ctx) + + // Reset the accumulated time + accumulatedTime = 0 + } + }, { immediate: true }) + + onUnmounted(() => { + pause() + }) + + return ctx +} + +export function useTresContext(): TresContext { + const context = inject>('useTres') + + if (!context) { + throw new Error('useTresContext must be used together with useTresContextProvider') + } + + return context as TresContext +} + +export const useTres = useTresContext \ No newline at end of file From ca6a6245b7f91f7dd36014f7a8a151c2136b99de Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 Jan 2025 17:12:58 +0300 Subject: [PATCH 3/5] + --- src/utils/renderers/tres/TresCanvas.ts | 38 +++- src/utils/renderers/tres/sample.vue | 269 +++++++++++++++++++++++++ 2 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/utils/renderers/tres/sample.vue diff --git a/src/utils/renderers/tres/TresCanvas.ts b/src/utils/renderers/tres/TresCanvas.ts index 874d9ee3..60b1ab1f 100644 --- a/src/utils/renderers/tres/TresCanvas.ts +++ b/src/utils/renderers/tres/TresCanvas.ts @@ -1,5 +1,18 @@ -import { $_tag, $_fin, Component, $_GET_ARGS, $_GET_SLOTS, $_slot } from '@lifeart/gxt'; -import { getContext, provideContext, RENDERING_CONTEXT, ROOT_CONTEXT } from '@/utils/context'; +import { + $_tag, + $_fin, + Component, + $_GET_ARGS, + $_GET_SLOTS, + $_slot, + Root, +} from '@lifeart/gxt'; +import { + getContext, + provideContext, + RENDERING_CONTEXT, + ROOT_CONTEXT, +} from '@/utils/context'; import { TresBrowserDOMApi } from './tres-api'; // { + const nodes = $slots.default(root); + nodes.forEach((node: unknown) => { + api.insert(canvasNode, node); + }); + console.log('$slots', nodes); + }); + // $_slot("default", () => [canvasNode], $slots, self)] // @ts-expect-error - return $_fin([canvasNode, $_slot("default", () => [canvasNode], $slots, self)], this); + return $_fin([canvasNode], this); } diff --git a/src/utils/renderers/tres/sample.vue b/src/utils/renderers/tres/sample.vue new file mode 100644 index 00000000..d5f6a501 --- /dev/null +++ b/src/utils/renderers/tres/sample.vue @@ -0,0 +1,269 @@ + + + \ No newline at end of file From 9f54f0e6ecd8b17675cbf398932bb82230e1a9de Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 Jan 2025 19:09:58 +0300 Subject: [PATCH 4/5] + --- src/components/pages/PageOne.gts | 5 +- src/utils/renderers/tres/TresCanvas.ts | 57 +- src/utils/renderers/tres/loop.ts | 204 ++++++ src/utils/renderers/tres/revision.ts | 4 + src/utils/renderers/tres/tres-api.ts | 3 +- src/utils/renderers/tres/types.d.ts | 3 + src/utils/renderers/tres/useCamera.ts | 85 +++ src/utils/renderers/tres/useLogger.ts | 48 ++ src/utils/renderers/tres/useRaycaster.ts | 209 +++++++ src/utils/renderers/tres/useRenderer.ts | 282 +++++++++ src/utils/renderers/tres/useSizes.ts | 9 + .../renderers/tres/useTresContextProvider.ts | 74 ++- .../renderers/tres/useTresEventManager.ts | 226 +++++++ src/utils/renderers/tres/useTresReady.ts | 65 ++ src/utils/renderers/tres/utils.ts | 263 -------- src/utils/renderers/tres/utils/index.ts | 584 ++++++++++++++++++ src/utils/renderers/tres/utils/is.ts | 68 ++ src/utils/renderers/tres/utils/normalize.ts | 42 ++ src/utils/renderers/tres/vue.ts | 69 +++ 19 files changed, 1997 insertions(+), 303 deletions(-) create mode 100644 src/utils/renderers/tres/loop.ts create mode 100644 src/utils/renderers/tres/revision.ts create mode 100644 src/utils/renderers/tres/useCamera.ts create mode 100644 src/utils/renderers/tres/useLogger.ts create mode 100644 src/utils/renderers/tres/useRaycaster.ts create mode 100644 src/utils/renderers/tres/useRenderer.ts create mode 100644 src/utils/renderers/tres/useSizes.ts create mode 100644 src/utils/renderers/tres/useTresEventManager.ts create mode 100644 src/utils/renderers/tres/useTresReady.ts delete mode 100644 src/utils/renderers/tres/utils.ts create mode 100644 src/utils/renderers/tres/utils/index.ts create mode 100644 src/utils/renderers/tres/utils/is.ts create mode 100644 src/utils/renderers/tres/utils/normalize.ts create mode 100644 src/utils/renderers/tres/vue.ts diff --git a/src/components/pages/PageOne.gts b/src/components/pages/PageOne.gts index 1270780e..cf325703 100644 --- a/src/components/pages/PageOne.gts +++ b/src/components/pages/PageOne.gts @@ -31,9 +31,8 @@ export function PageOne() {
- - - + + diff --git a/src/utils/renderers/tres/TresCanvas.ts b/src/utils/renderers/tres/TresCanvas.ts index 60b1ab1f..97bc1ec2 100644 --- a/src/utils/renderers/tres/TresCanvas.ts +++ b/src/utils/renderers/tres/TresCanvas.ts @@ -14,6 +14,10 @@ import { ROOT_CONTEXT, } from '@/utils/context'; import { TresBrowserDOMApi } from './tres-api'; +import { useTresContextProvider } from './useTresContextProvider'; +import { PerspectiveCamera, Scene } from 'three'; +import { TresScene } from './types'; +import { watchEffect } from './vue'; // { + const existingCanvas = canvasNode; + const scene = new Scene(); + + const nodes = $slots.default(root); nodes.forEach((node: unknown) => { - api.insert(canvasNode, node); + api.insert(scene, node); }); + + let context = useTresContextProvider({ + scene: scene as TresScene, + canvas: existingCanvas, + windowSize: false, + rendererOptions: {}, + emit: {}, + }); + const { registerCamera, camera, cameras, deregisterCamera } = context; + + console.log({ + registerCamera, camera, cameras, deregisterCamera + }) + + + const addDefaultCamera = () => { + const camera = new PerspectiveCamera( + 45, + window.innerWidth / window.innerHeight, + 0.1, + 1000, + ) + camera.position.set(3, 3, 3) + camera.lookAt(0, 0, 0) + registerCamera(camera) + + const unwatch = watchEffect(() => { + if (cameras.value.length >= 2) { + camera.removeFromParent() + deregisterCamera(camera) + unwatch?.() + } + }) + } + + // debugger; + if (!camera.value) { + console.warn( + 'No camera found. Creating a default perspective camera. ' + + 'To have full control over a camera, please add one to the scene.', + ) + addDefaultCamera() + } + + + // mountCustomRenderer(context); + console.log('$slots', nodes); }); // $_slot("default", () => [canvasNode], $slots, self)] diff --git a/src/utils/renderers/tres/loop.ts b/src/utils/renderers/tres/loop.ts new file mode 100644 index 00000000..d9c24fd6 --- /dev/null +++ b/src/utils/renderers/tres/loop.ts @@ -0,0 +1,204 @@ +// import type { Fn } from '@vueuse/core' +import type { Camera, EventDispatcher, Raycaster, Scene, WebGLRenderer } from 'three' +// import type { Ref } from 'vue' +// import type { Callback } from '../utils/createPriorityEventHook' +import { Clock, MathUtils } from 'three' +import { ref, unref } from './vue' + +export type LoopStage = 'before' | 'render' | 'after' + +function createPriorityEventHook(debueName: string) { + let ons = []; + return { + trigger(p) { + console.log(debueName + ':createPriorityEventHook:trigger', ...arguments); + debugger; + ons.forEach((el) => el(p)); + }, + dispose() { + console.log(debueName + ':createPriorityEventHook:dispose', ...arguments); + }, + on(a: any) { + ons.push(a); + console.log(debueName + ':createPriorityEventHook:on', a); + } + } +} + +export interface LoopCallback { + delta: number + elapsed: number + clock: Clock +} + +export interface LoopCallbackWithCtx extends LoopCallback { + camera: Camera + scene: Scene + renderer: WebGLRenderer + raycaster: Raycaster + controls: Ref<(EventDispatcher & { + enabled: boolean + }) | null> + invalidate: Fn + advance: Fn +} + +export type LoopCallbackFn = (params: LoopCallbackWithCtx) => void + +export interface RendererLoop { + loopId: string + register: (callback: LoopCallbackFn, stage: LoopStage, index?: number) => { off: Fn } + start: Fn + stop: Fn + pause: Fn + resume: Fn + pauseRender: Fn + resumeRender: Fn + isActive: Ref + isRenderPaused: Ref + setContext: (newContext: Record) => void + setReady: (isReady: boolean) => void +} + +export function createRenderLoop(): RendererLoop { + let isReady = true + let isStopped = true + let isPaused = false + const clock = new Clock(false) + const isActive = ref(clock.running) + const isRenderPaused = ref(false) + let animationFrameId: number + const loopId = MathUtils.generateUUID() + let defaultRenderFn: Callback | null = null + const subscribersBefore = createPriorityEventHook('subscribersBefore') + const subscriberRender = createPriorityEventHook('subscriberRender') + const subscribersAfter = createPriorityEventHook('subscribersAfter') + + _syncState() + + // Context to be passed to callbacks + let context: Record = {} + + function setContext(newContext: Record) { + context = newContext + } + + function registerCallback(callback: LoopCallbackFn, stage: 'before' | 'render' | 'after', index = 0): { off: Fn } { + switch (stage) { + case 'before': + return subscribersBefore.on(callback, index) + case 'render': + if (!defaultRenderFn) { + defaultRenderFn = callback + } + subscriberRender.dispose() + return subscriberRender.on(callback) + case 'after': + return subscribersAfter.on(callback, index) + } + } + + function start() { + // NOTE: `loop()` produces side effects on each call. + // Those side effects are only desired if `isStopped` goes + // from `true` to `false` below. So while we don't need + // a guard in `stop`, `resume`, and `pause`, we do need + // a guard here. + if (!isStopped) { return } + isStopped = false + _syncState() + loop() + } + + function stop() { + isStopped = true + _syncState() + cancelAnimationFrame(animationFrameId) + } + + function resume() { + isPaused = false + _syncState() + } + + function pause() { + isPaused = true + _syncState() + } + + function pauseRender() { + isRenderPaused.value = true + } + + function resumeRender() { + isRenderPaused.value = false + } + + function loop() { + if (!isReady) { + animationFrameId = requestAnimationFrame(loop) + return + } + const delta = clock.getDelta() + const elapsed = clock.getElapsedTime() + const snapshotCtx = { + camera: unref(context.camera), + scene: unref(context.scene), + renderer: unref(context.renderer), + raycaster: unref(context.raycaster), + controls: unref(context.controls), + invalidate: context.invalidate, + advance: context.advance, + } + const params = { delta, elapsed, clock, ...snapshotCtx } + + if (isActive.value) { + subscribersBefore.trigger(params) + } + + if (!isRenderPaused.value) { + if (subscriberRender.count) { + subscriberRender.trigger(params) + } + else { + if (defaultRenderFn) { + defaultRenderFn(params) // <-- keep the default render function separate + } + } + } + + if (isActive.value) { + subscribersAfter.trigger(params) + } + + animationFrameId = requestAnimationFrame(loop) + } + + function _syncState() { + const shouldClockBeRunning = !isStopped && !isPaused + if (clock.running !== shouldClockBeRunning) { + if (!clock.running) { + clock.start() + } + else { + clock.stop() + } + } + isActive.value = clock.running + } + + return { + loopId, + register: (callback: LoopCallbackFn, stage: 'before' | 'render' | 'after', index) => registerCallback(callback, stage, index), + start, + stop, + pause, + resume, + pauseRender, + resumeRender, + isRenderPaused, + isActive, + setContext, + setReady: (b: boolean) => isReady = b, + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/revision.ts b/src/utils/renderers/tres/revision.ts new file mode 100644 index 00000000..32251849 --- /dev/null +++ b/src/utils/renderers/tres/revision.ts @@ -0,0 +1,4 @@ +import { REVISION } from 'three' + +// REVISION can be '{number}' or '{number}dev' +export const revision = Number.parseInt(REVISION.replace('dev', '')) \ No newline at end of file diff --git a/src/utils/renderers/tres/tres-api.ts b/src/utils/renderers/tres/tres-api.ts index f6e15226..da21941a 100644 --- a/src/utils/renderers/tres/tres-api.ts +++ b/src/utils/renderers/tres/tres-api.ts @@ -1,6 +1,6 @@ import { BufferAttribute } from 'three' import type { Camera, Object3D } from 'three' -import { deepArrayEqual, isHTMLTag, kebabToCamel } from './utils' +import { deepArrayEqual, isHTMLTag, kebabToCamel } from './utils/index' import type { TresObject, TresObject3D, TresScene } from './types' import { catalogue } from './catalogue' @@ -102,7 +102,6 @@ export class TresBrowserDOMApi implements DOMApi { // @ts-expect-error insert(parent, child) { if (parent && parent.isScene) { scene = parent as unknown as TresScene } - const parentObject = parent || scene if (child?.isObject3D) { diff --git a/src/utils/renderers/tres/types.d.ts b/src/utils/renderers/tres/types.d.ts index f977eb4f..4f09ee6f 100644 --- a/src/utils/renderers/tres/types.d.ts +++ b/src/utils/renderers/tres/types.d.ts @@ -19,6 +19,9 @@ export interface TresCatalogue { } export type TresCamera = THREE.OrthographicCamera | THREE.PerspectiveCamera +export type TresPrimitive = TresInstance & { object: TresInstance, isPrimitive: true } + + export interface InstanceProps { args?: Args

object?: T diff --git a/src/utils/renderers/tres/useCamera.ts b/src/utils/renderers/tres/useCamera.ts new file mode 100644 index 00000000..2a442af0 --- /dev/null +++ b/src/utils/renderers/tres/useCamera.ts @@ -0,0 +1,85 @@ +import type { OrthographicCamera } from 'three' +import type { TresScene } from './types' +import type { TresContext } from './useTresContextProvider' + +import { Camera, PerspectiveCamera } from 'three' +import { computed, onUnmounted, ref, watchEffect } from './vue' +import { camera as isCamera } from './utils/is' + +export const useCamera = ({ sizes }: Pick & { scene: TresScene }) => { + // the computed does not trigger, when for example the camera position changes + const cameras = ref([]) + const camera = computed( + () => cameras.value[0], + ) + + const setCameraActive = (cameraOrUuid: string | Camera) => { + const camera = cameraOrUuid instanceof Camera + ? cameraOrUuid + : cameras.value.find((camera: Camera) => camera.uuid === cameraOrUuid) + + if (!camera) { return } + + const otherCameras = cameras.value.filter(({ uuid }) => uuid !== camera.uuid) + cameras.value = [camera, ...otherCameras] + } + + const registerCamera = (maybeCamera: unknown, active = false) => { + if (isCamera(maybeCamera)) { + const camera = maybeCamera + if (cameras.value.some(({ uuid }) => uuid === camera.uuid)) { return } + + if (active) { setCameraActive(camera) } + else { cameras.value.push(camera) } + } + } + + const deregisterCamera = (maybeCamera: unknown) => { + if (isCamera(maybeCamera)) { + const camera = maybeCamera + cameras.value = cameras.value.filter(({ uuid }) => uuid !== camera.uuid) + } + } + + watchEffect(() => { + if (sizes.aspectRatio.value) { + cameras.value.forEach((camera: Camera & { manual?: boolean }) => { + // NOTE: Don't mess with the camera if it belongs to the user. + // https://github.com/pmndrs/react-three-fiber/blob/0ef66a1d23bf16ecd457dde92b0517ceec9861c5/packages/fiber/src/core/utils.ts#L457 + // + // To set camera as "manual": + // const myCamera = new PerspectiveCamera(); // or OrthographicCamera + // (myCamera as any).manual = true + if (!camera.manual && (camera instanceof PerspectiveCamera || isOrthographicCamera(camera))) { + if (camera instanceof PerspectiveCamera) { + camera.aspect = sizes.aspectRatio.value + } + else { + camera.left = sizes.width.value * -0.5 + camera.right = sizes.width.value * 0.5 + camera.top = sizes.height.value * 0.5 + camera.bottom = sizes.height.value * -0.5 + } + camera.updateProjectionMatrix() + } + }) + } + }) + + onUnmounted(() => { + cameras.value = [] + }) + + return { + camera, + cameras, + registerCamera, + deregisterCamera, + setCameraActive, + } +} + +function isOrthographicCamera(o: any): o is OrthographicCamera { + // eslint-disable-next-line no-prototype-builtins + return o.hasOwnProperty('isOrthographicCamera') && o.isOrthographicCamera +} \ No newline at end of file diff --git a/src/utils/renderers/tres/useLogger.ts b/src/utils/renderers/tres/useLogger.ts new file mode 100644 index 00000000..a29398cf --- /dev/null +++ b/src/utils/renderers/tres/useLogger.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +export const isProd = import.meta.env.MODE === 'production' + +const logPrefix = '[TresJS ▲ ■ ●] ' + +type OneOrMore = { 0: T } & Array + +interface LoggerComposition { + logError: (...args: OneOrMore) => void + logWarning: (...args: OneOrMore) => void + logMessage: (name: string, value: any) => void +} + +function logError(...args: OneOrMore) { + if (typeof args[0] === 'string') { + // NOTE: Don't break console string substitution + args[0] = logPrefix + args[0] + } + else { + args.unshift(logPrefix) + } + console.error(...args) +} + +function logWarning(...args: OneOrMore) { + if (typeof args[0] === 'string') { + // NOTE: Don't break console string substitution + args[0] = logPrefix + args[0] + } + else { + args.unshift(logPrefix) + } + console.warn(...args) +} + +function logMessage(name: string, value: any) { + if (!isProd) { + console.log(`${logPrefix} - ${name}:`, value) + } +} +/* eslint-enable no-console */ +export function useLogger(): LoggerComposition { + return { + logError, + logWarning, + logMessage, + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/useRaycaster.ts b/src/utils/renderers/tres/useRaycaster.ts new file mode 100644 index 00000000..c5794653 --- /dev/null +++ b/src/utils/renderers/tres/useRaycaster.ts @@ -0,0 +1,209 @@ +import type { DomEvent, TresCamera, TresEvent, TresInstance } from './types' +import type { Intersection, Object3D, Object3DEventMap } from 'three' +import type { TresContext } from './useTresContextProvider' + +import { Vector2, Vector3 } from 'three' +import { computed, onUnmounted, shallowRef } from './vue' + +export const useRaycaster = ( + objectsWithEvents: ShallowRef, + ctx: TresContext, +) => { + // having a separate computed makes useElementBounding work + const canvas = computed(() => ctx.renderer.value.domElement as HTMLCanvasElement) + const intersects: ShallowRef = shallowRef([]) + const { x, y } = usePointer({ target: canvas }) + let delta = 0 + + const { width, height, top, left } = useElementBounding(canvas) + + const getRelativePointerPosition = ({ x, y }: { x: number, y: number }) => { + if (!canvas.value) { return } + + return { + x: ((x - left.value) / width.value) * 2 - 1, + y: -((y - top.value) / height.value) * 2 + 1, + } + } + + const getIntersectsByRelativePointerPosition = ({ x, y }: { x: number, y: number }) => { + if (!ctx.camera.value) { return } + + ctx.raycaster.value.setFromCamera(new Vector2(x, y), ctx.camera.value) + + intersects.value = ctx.raycaster.value.intersectObjects(objectsWithEvents.value as Object3D[], true) + return intersects.value + } + + const getIntersects = (event?: DomEvent) => { + const pointerPosition = getRelativePointerPosition({ + x: event?.clientX ?? x.value, + y: event?.clientY ?? y.value, + }) + if (!pointerPosition) { return [] } + + return getIntersectsByRelativePointerPosition(pointerPosition) || [] + } + + const eventHookClick = createEventHook() + const eventHookDblClick = createEventHook() + const eventHookPointerMove = createEventHook() + const eventHookPointerUp = createEventHook() + const eventHookPointerDown = createEventHook() + const eventHookPointerMissed = createEventHook() + const eventHookContextMenu = createEventHook() + const eventHookWheel = createEventHook() + + /* ({ + ...DomEvent // All the original event data + ...Intersection // All of Three's intersection data - see note 2 + intersections: Intersection[] // The first intersection of each intersected object + object: Object3D // The object that was actually hit (added to event payload in TresEventManager) + eventObject: Object3D // The object that registered the event (added to event payload in TresEventManager) + unprojectedPoint: Vector3 // Camera-unprojected point + ray: Ray // The ray that was used to strike the object + camera: Camera // The camera that was used in the raycaster + sourceEvent: DomEvent // A reference to the host event + delta: number // Distance between mouse down and mouse up event in pixels + }) => ... */ + + // Mouse Event props aren't enumerable, so we can't be simple and use Object.assign or the spread operator + // Manually copies the mouse event props into a new object that we can spread in triggerEventHook + function copyMouseEventProperties(event: MouseEvent | PointerEvent | WheelEvent) { + const mouseEventProperties: any = {} + + for (const property in event) { + // Copy all non-function properties + if (typeof property !== 'function') { mouseEventProperties[property] = (event as Record)[property] } + } + return mouseEventProperties + } + + const triggerEventHook = (eventHook: EventHook, event: PointerEvent | MouseEvent | WheelEvent) => { + const eventProperties = copyMouseEventProperties(event) + const unprojectedPoint = new Vector3(event?.clientX, event?.clientY, 0).unproject(ctx.camera?.value as TresCamera) + eventHook.trigger({ + ...eventProperties, + intersections: intersects.value, + // The unprojectedPoint is wrong, math needs to be fixed + unprojectedPoint, + ray: ctx.raycaster?.value.ray, + camera: ctx.camera?.value, + sourceEvent: event, + delta, + stopPropagating: false, + }) + } + + let previousPointerMoveEvent: PointerEvent | undefined + const onPointerMove = (event: PointerEvent) => { + // Update the raycast intersects + getIntersects(event) + triggerEventHook(eventHookPointerMove, event) + previousPointerMoveEvent = event + } + + const forceUpdate = () => { + if (previousPointerMoveEvent) { onPointerMove(previousPointerMoveEvent) } + } + + // a click event is fired whenever a pointerdown happened after pointerup on the same object + let mouseDownObject: Object3D | undefined + let mouseDownPosition: Vector2 + let mouseUpPosition: Vector2 + + const onPointerDown = (event: PointerEvent) => { + mouseDownObject = intersects.value[0]?.object + + delta = 0 + mouseDownPosition = new Vector2( + event?.clientX ?? x.value, + event?.clientY ?? y.value, + ) + + triggerEventHook(eventHookPointerDown, event) + } + + let previousClickObject: Object3D | undefined + let doubleClickConfirmed: boolean = false + + const onPointerUp = (event: MouseEvent) => { + if (!(event instanceof PointerEvent)) { return } // prevents triggering twice on mobile devices + + // We missed every object, trigger the pointer missed event + if (intersects.value.length === 0) { + triggerEventHook(eventHookPointerMissed, event) + } + + if (mouseDownObject === intersects.value[0]?.object) { + mouseUpPosition = new Vector2( + event?.clientX ?? x.value, + event?.clientY ?? y.value, + ) + + // Compute the distance between the mouse down and mouse up events + delta = mouseDownPosition?.distanceTo(mouseUpPosition) + + if (event.button === 0) { + // Left click + triggerEventHook(eventHookClick, event) + + if (previousClickObject === intersects.value[0]?.object) { + doubleClickConfirmed = true + } + else { + previousClickObject = intersects.value[0]?.object + doubleClickConfirmed = false + } + } + else if (event.button === 2) { + // Right click + triggerEventHook(eventHookContextMenu, event) + } + } + + triggerEventHook(eventHookPointerUp, event) + } + + const onDoubleClick = (event: MouseEvent) => { + if (doubleClickConfirmed) { + triggerEventHook(eventHookDblClick, event) + previousClickObject = undefined + doubleClickConfirmed = false + } + } + + const onPointerLeave = (event: PointerEvent) => triggerEventHook(eventHookPointerMove, event) + + const onWheel = (event: WheelEvent) => triggerEventHook(eventHookWheel, event) + + canvas.value.addEventListener('pointerup', onPointerUp) + canvas.value.addEventListener('pointerdown', onPointerDown) + canvas.value.addEventListener('pointermove', onPointerMove) + canvas.value.addEventListener('pointerleave', onPointerLeave) + canvas.value.addEventListener('dblclick', onDoubleClick) + canvas.value.addEventListener('wheel', onWheel) + + onUnmounted(() => { + if (!canvas?.value) { return } + canvas.value.removeEventListener('pointerup', onPointerUp) + canvas.value.removeEventListener('pointerdown', onPointerDown) + canvas.value.removeEventListener('pointermove', onPointerMove) + canvas.value.removeEventListener('pointerleave', onPointerLeave) + canvas.value.removeEventListener('dblclick', onDoubleClick) + canvas.value.removeEventListener('wheel', onWheel) + }) + + return { + intersects, + onClick: (fn: (value: TresEvent) => void) => eventHookClick.on(fn).off, + onDblClick: (fn: (value: TresEvent) => void) => eventHookDblClick.on(fn).off, + onContextMenu: (fn: (value: TresEvent) => void) => eventHookContextMenu.on(fn).off, + onPointerMove: (fn: (value: TresEvent) => void) => eventHookPointerMove.on(fn).off, + onPointerUp: (fn: (value: TresEvent) => void) => eventHookPointerUp.on(fn).off, + onPointerDown: (fn: (value: TresEvent) => void) => eventHookPointerDown.on(fn).off, + onPointerMissed: (fn: (value: TresEvent) => void) => eventHookPointerMissed.on(fn).off, + onWheel: (fn: (value: TresEvent) => void) => eventHookWheel.on(fn).off, + forceUpdate, + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/useRenderer.ts b/src/utils/renderers/tres/useRenderer.ts new file mode 100644 index 00000000..5d9a2f44 --- /dev/null +++ b/src/utils/renderers/tres/useRenderer.ts @@ -0,0 +1,282 @@ +import type { ColorSpace, Scene, ShadowMapType, ToneMapping, WebGLRendererParameters } from 'three' +import type { EmitEventFn, TresColor } from './types' + +import type { TresContext } from '../useTresContextProvider' + +import type { RendererPresetsType } from './const'; +const toValue = (el) => { + console.log('to-value', el); + return el; +} + +import { ACESFilmicToneMapping, Color, WebGLRenderer } from 'three' +import { computed, type MaybeRef, onUnmounted, shallowRef, watch, watchEffect, unrefElement } from './vue' + +// Solution taken from Thretle that actually support different versions https://github.com/threlte/threlte/blob/5fa541179460f0dadc7dc17ae5e6854d1689379e/packages/core/src/lib/lib/useRenderer.ts +import { revision } from './revision' +import { get, merge, set, setPixelRatio } from './utils' + +import { normalizeColor } from './utils/normalize' +import { useLogger } from './useLogger' + +import { NoToneMapping, PCFSoftShadowMap, SRGBColorSpace } from 'three' + +export const rendererPresets = { + realistic: { + shadows: true, + physicallyCorrectLights: true, + outputColorSpace: SRGBColorSpace, + toneMapping: ACESFilmicToneMapping, + toneMappingExposure: 3, + shadowMap: { + enabled: true, + type: PCFSoftShadowMap, + }, + }, + flat: { + toneMapping: NoToneMapping, + toneMappingExposure: 1, + }, +} + +export type RendererPresetsType = keyof typeof rendererPresets + +type TransformToMaybeRefOrGetter = { + [K in keyof T]: MaybeRefOrGetter | MaybeRefOrGetter; +} + +export interface UseRendererOptions extends TransformToMaybeRefOrGetter { + /** + * Enable shadows in the Renderer + * + * @default false + */ + shadows?: MaybeRefOrGetter + + /** + * Set the shadow map type + * Can be PCFShadowMap, PCFSoftShadowMap, BasicShadowMap, VSMShadowMap + * [see](https://threejs.org/docs/?q=we#api/en/constants/Renderer) + * + * @default PCFSoftShadowMap + */ + shadowMapType?: MaybeRefOrGetter + + /** + * Whether to use physically correct lighting mode. + * See the [lights / physical example](https://threejs.org/examples/#webgl_lights_physical). + * + * @default false + * @deprecated Use {@link WebGLRenderer.useLegacyLights useLegacyLights} instead. + */ + physicallyCorrectLights?: MaybeRefOrGetter + /** + * Whether to use legacy lighting mode. + * + * @type {MaybeRefOrGetter} + * @memberof UseRendererOptions + */ + useLegacyLights?: MaybeRefOrGetter + /** + * Defines the output encoding of the renderer. + * Can be LinearSRGBColorSpace, SRGBColorSpace + * + * @default LinearSRGBColorSpace + */ + outputColorSpace?: MaybeRefOrGetter + + /** + * Defines the tone mapping used by the renderer. + * Can be NoToneMapping, LinearToneMapping, + * ReinhardToneMapping, Uncharted2ToneMapping, + * CineonToneMapping, ACESFilmicToneMapping, + * CustomToneMapping + * + * @default ACESFilmicToneMapping + */ + toneMapping?: MaybeRefOrGetter + + /** + * Defines the tone mapping exposure used by the renderer. + * + * @default 1 + */ + toneMappingExposure?: MaybeRefOrGetter + + /** + * The color value to use when clearing the canvas. + * + * @default 0x000000 + */ + clearColor?: MaybeRefOrGetter + windowSize?: MaybeRefOrGetter + preset?: MaybeRefOrGetter + renderMode?: MaybeRefOrGetter<'always' | 'on-demand' | 'manual'> + /** + * A `number` sets the renderer's device pixel ratio. + * `[number, number]` clamp's the renderer's device pixel ratio. + */ + dpr?: MaybeRefOrGetter +} + +export function useRenderer( + { + canvas, + options, + contextParts: { sizes, render, invalidate, advance }, + }: + { + canvas: MaybeRef + scene: Scene + options: UseRendererOptions + emit: EmitEventFn + contextParts: Pick & { invalidate: () => void, advance: () => void } + }, +) { + const webGLRendererConstructorParameters = computed(() => ({ + alpha: toValue(options.alpha) ?? true, + depth: toValue(options.depth), + canvas: unrefElement(canvas), + context: toValue(options.context), + stencil: toValue(options.stencil), + antialias: toValue(options.antialias) ?? true, + precision: toValue(options.precision), + powerPreference: toValue(options.powerPreference), + premultipliedAlpha: toValue(options.premultipliedAlpha), + preserveDrawingBuffer: toValue(options.preserveDrawingBuffer), + logarithmicDepthBuffer: toValue(options.logarithmicDepthBuffer), + failIfMajorPerformanceCaveat: toValue(options.failIfMajorPerformanceCaveat), + })) + + const renderer = shallowRef(new WebGLRenderer(webGLRendererConstructorParameters.value)) + + function invalidateOnDemand() { + if (options.renderMode === 'on-demand') { + invalidate() + } + } + // since the properties set via the constructor can't be updated dynamically, + // the renderer is recreated once they change + watch(webGLRendererConstructorParameters, () => { + renderer.value.dispose() + renderer.value = new WebGLRenderer(webGLRendererConstructorParameters.value) + + invalidateOnDemand() + }) + + watch([sizes.width, sizes.height], () => { + renderer.value.setSize(sizes.width.value, sizes.height.value) + invalidateOnDemand() + }, { + immediate: true, + }) + + watch(() => options.clearColor, invalidateOnDemand) + + const pixelRatio = 2; + + const { logError } = useLogger() + + const getThreeRendererDefaults = () => { + const plainRenderer = new WebGLRenderer() + + const defaults = { + shadowMap: { + enabled: plainRenderer.shadowMap.enabled, + type: plainRenderer.shadowMap.type, + }, + toneMapping: plainRenderer.toneMapping, + toneMappingExposure: plainRenderer.toneMappingExposure, + outputColorSpace: plainRenderer.outputColorSpace, + } + plainRenderer.dispose() + + return defaults + } + + const threeDefaults = getThreeRendererDefaults() + + const renderMode = toValue(options.renderMode) + + if (renderMode === 'on-demand') { + // Invalidate for the first time + invalidate() + } + + if (renderMode === 'manual') { + // Advance for the first time, setTimeout to make sure there is something to render + setTimeout(() => { + advance() + }, 100) + } + + watchEffect(() => { + const rendererPreset = toValue(options.preset) + + if (rendererPreset) { + if (!(rendererPreset in rendererPresets)) { logError(`Renderer Preset must be one of these: ${Object.keys(rendererPresets).join(', ')}`) } + + merge(renderer.value, rendererPresets[rendererPreset]) + } + + setPixelRatio(renderer.value, pixelRatio.value, toValue(options.dpr)) + + // Render mode + + if (renderMode === 'always') { + // If the render mode is 'always', ensure there's always a frame pending + render.frames.value = Math.max(1, render.frames.value) + } + + const getValue = (option: MaybeRefOrGetter, pathInThree: string): T | undefined => { + const value = toValue(option) + + const getValueFromPreset = () => { + if (!rendererPreset) { return } + + return get(rendererPresets[rendererPreset], pathInThree) + } + + if (value !== undefined) { return value } + + const valueInPreset = getValueFromPreset() as T + + if (valueInPreset !== undefined) { return valueInPreset } + + return get(threeDefaults, pathInThree) + } + + const setValueOrDefault = (option: MaybeRefOrGetter, pathInThree: string) => + set(renderer.value, pathInThree, getValue(option, pathInThree)) + + setValueOrDefault(options.shadows, 'shadowMap.enabled') + setValueOrDefault(options.toneMapping ?? ACESFilmicToneMapping, 'toneMapping') + setValueOrDefault(options.shadowMapType, 'shadowMap.type') + + if (revision < 150) { setValueOrDefault(!options.useLegacyLights, 'physicallyCorrectLights') } + + setValueOrDefault(options.outputColorSpace, 'outputColorSpace') + setValueOrDefault(options.toneMappingExposure, 'toneMappingExposure') + + const clearColor = getValue(options.clearColor, 'clearColor') + + if (clearColor) { + renderer.value.setClearColor( + clearColor + ? normalizeColor(clearColor) + : new Color(0x000000), // default clear color is not easily/efficiently retrievable from three + ) + } + }) + + onUnmounted(() => { + renderer.value.dispose() + renderer.value.forceContextLoss() + }) + + return { + renderer, + } +} + +export type UseRendererReturn = ReturnType \ No newline at end of file diff --git a/src/utils/renderers/tres/useSizes.ts b/src/utils/renderers/tres/useSizes.ts new file mode 100644 index 00000000..994471b6 --- /dev/null +++ b/src/utils/renderers/tres/useSizes.ts @@ -0,0 +1,9 @@ + +export default function useSizes() { + + return { + height: 320, + width: 240, + aspectRatio: 2, + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/useTresContextProvider.ts b/src/utils/renderers/tres/useTresContextProvider.ts index 283fb9c6..93384988 100644 --- a/src/utils/renderers/tres/useTresContextProvider.ts +++ b/src/utils/renderers/tres/useTresContextProvider.ts @@ -1,20 +1,19 @@ import type { Camera, WebGLRenderer } from 'three' -import type { ComputedRef, DeepReadonly, MaybeRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue' -import type { RendererLoop } from '../../core/loop' -import type { EmitEventFn, TresControl, TresObject, TresScene } from '../../types' -import type { UseRendererOptions } from '../useRenderer' -import { useFps, useMemory, useRafFn } from '@vueuse/core' +import type { ComputedRef, DeepReadonly, MaybeRef, MaybeRefOrGetter, Ref, ShallowRef } from './vue' +import type { RendererLoop } from './loop' +import type { EmitEventFn, TresControl, TresObject, TresScene } from './types' +import type { UseRendererOptions } from './useRenderer' +import { useFps, useMemory, useRafFn } from './vue' import { Raycaster } from 'three' -import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef } from 'vue' -import { extend } from '../../core/catalogue' -import { createRenderLoop } from '../../core/loop' -import { calculateMemoryUsage } from '../../utils/perf' +import { computed, inject, onUnmounted, provide, readonly, ref, shallowRef } from './vue' +import { extend } from './catalogue' +import { createRenderLoop } from './loop' -import { useCamera } from '../useCamera' -import { useRenderer } from '../useRenderer' -import useSizes, { type SizesType } from '../useSizes' -import { type TresEventManager, useTresEventManager } from '../useTresEventManager' -import { useTresReady } from '../useTresReady' +import { useCamera } from './useCamera' +import { useRenderer } from './useRenderer' +import useSizes from './useSizes' +import { type TresEventManager, useTresEventManager } from './useTresEventManager' +import { useTresReady } from './useTresReady' export interface InternalState { priority: Ref @@ -115,19 +114,19 @@ export function useTresContextProvider({ priority: ref(0), frames: ref(0), maxFrames: 60, - canBeInvalidated: computed(() => render.mode.value === 'on-demand' && render.frames.value === 0), + canBeInvalidated: computed(() => render.mode === 'on-demand' && render.frames === 0), } function invalidate(frames = 1) { // Increase the frame count, ensuring not to exceed a maximum if desired if (rendererOptions.renderMode === 'on-demand') { - render.frames.value = Math.min(render.maxFrames, render.frames.value + frames) + render.frames = Math.min(render.maxFrames, render.frames + frames) } } function advance() { if (rendererOptions.renderMode === 'manual') { - render.frames.value = 1 + render.frames = 1 } } @@ -138,7 +137,7 @@ export function useTresContextProvider({ options: rendererOptions, emit, // TODO: replace contextParts with full ctx at https://github.com/Tresjs/tres/issues/516 - contextParts: { sizes, camera, render, invalidate, advance }, + contextParts: { sizes, camera: camera.value, render, invalidate, advance }, }, ) @@ -172,29 +171,35 @@ export function useTresContextProvider({ loop: createRenderLoop(), } + debugger; provide('useTres', ctx) // Add context to scene local state - ctx.scene.value.__tres = { + ctx.scene.__tres = { root: ctx, } + ctx.loop.setContext(ctx); // The loop + + ctx.loop.register(() => { - if (camera.value && render.frames.value > 0) { - renderer.value.render(scene, camera.value) - emit('render', ctx.renderer.value) + debugger; + if (camera && render.frames > 0) { + debugger; + renderer.render(scene, camera.value) + emit('render', ctx.renderer) } // Reset priority - render.priority.value = 0 + render.priority = 0 - if (render.mode.value === 'always') { - render.frames.value = 1 + if (render.mode === 'always') { + render.frames = 1 } else { - render.frames.value = Math.max(0, render.frames.value - 1) + render.frames = Math.max(0, render.frames - 1) } }, 'render') @@ -204,7 +209,8 @@ export function useTresContextProvider({ ctx.loop.start() onTresReady(() => { - emit('ready', ctx) + // emit('ready', ctx) + debugger; ctx.loop.setReady(true) useTresEventManager(scene, ctx, emit) }) @@ -223,9 +229,9 @@ export function useTresContextProvider({ const updatePerformanceData = ({ timestamp }: { timestamp: number }) => { // Update WebGL Memory Usage (Placeholder for actual logic) - // perf.memory.value = calculateMemoryUsage(gl) - if (ctx.scene.value) { - ctx.perf.memory.allocatedMem = calculateMemoryUsage(ctx.scene.value as unknown as TresObject) + // perf.memory = calculateMemoryUsage(gl) + if (ctx.scene) { + ctx.perf.memory.allocatedMem = 12; } // Update memory usage @@ -233,17 +239,17 @@ export function useTresContextProvider({ lastUpdateTime = timestamp // Update FPS - ctx.perf.fps.accumulator.push(fps.value as never) + ctx.perf.fps.accumulator.push(fps as never) if (ctx.perf.fps.accumulator.length > maxFrames) { ctx.perf.fps.accumulator.shift() } - ctx.perf.fps.value = fps.value + ctx.perf.fps = fps // Update memory - if (isSupported.value && memory.value) { - ctx.perf.memory.accumulator.push(memory.value.usedJSHeapSize / 1024 / 1024 as never) + if (isSupported && memory) { + ctx.perf.memory.accumulator.push(memory.usedJSHeapSize / 1024 / 1024 as never) if (ctx.perf.memory.accumulator.length > maxFrames) { ctx.perf.memory.accumulator.shift() diff --git a/src/utils/renderers/tres/useTresEventManager.ts b/src/utils/renderers/tres/useTresEventManager.ts new file mode 100644 index 00000000..a5ef5c18 --- /dev/null +++ b/src/utils/renderers/tres/useTresEventManager.ts @@ -0,0 +1,226 @@ +import type { EmitEventFn, EmitEventName, Intersection, TresEvent, TresInstance, TresObject } from 'src/types' +import type { Object3D, Object3DEventMap, Scene } from 'three' +import type { TresContext } from './useTresContextProvider' +import { shallowRef } from './vue' +import { hyphenate } from './utils' +import * as is from './utils/is' +import { useRaycaster } from './useRaycaster' + +export interface TresEventManager { + /** + * Forces the event system to refire events with the previous mouse event + */ + forceUpdate: () => void + /** + * pointer-missed events by definition are fired when the pointer missed every object in the scene + * So we need to track them separately + * Note: These are used in nodeOps + */ + registerObject: (object: unknown) => void + deregisterObject: (object: unknown) => void + registerPointerMissedObject: (object: unknown) => void + deregisterPointerMissedObject: (object: unknown) => void +} + +function executeEventListeners( + listeners: (event: TresEvent) => void | ((event: TresEvent) => void)[], + event: TresEvent, +) { + // Components with multiple event listeners will have an array of functions + if (Array.isArray(listeners)) { + for (const listener of listeners) { + listener(event) + } + } + + // Single listener will be a function + if (typeof listeners === 'function') { + listeners(event) + } +} + +export function useTresEventManager( + scene: Scene, + context: TresContext, + emit: EmitEventFn, +) { + + const hasEvents = (object: TresInstance) => object.__tres?.eventCount > 0 + const hasChildrenWithEvents = (object: TresInstance) => object.children?.some((child: TresInstance) => hasChildrenWithEvents(child)) || hasEvents(object) + // TODO: Optimize to not hit test on the whole scene + const objectsWithEvents = shallowRef((scene?.children as TresInstance[]).filter(hasChildrenWithEvents) || []) + + /** + * propogateEvent + * + * Propogates an event to all intersected objects and their parents + * @param eventName - The name of the event to propogate + * @param event - The event object to propogate + */ + function propogateEvent(eventName: string, event: TresEvent) { + // Array of objects we've already propogated to + const duplicates = [] + + // Flag that is set to true when the stopProgatingFn is called + const stopPropagatingFn = () => (event.stopPropagating = true) + event.stopPropagation = stopPropagatingFn + + // Loop through all intersected objects and call their event handler + for (const intersection of event?.intersections) { + if (event.stopPropagating) { return } + + // Add intersection data to event object + event = { ...event, ...intersection } + + const { object } = intersection + event.eventObject = object as TresObject + executeEventListeners((object as Record)[eventName], event) + duplicates.push(object) + + // Propogate the event up the parent chain before moving on to the next intersected object + let parentObj = object.parent + while (parentObj !== null && !event.stopPropagating) { + // We've already been here, break the loop + if (duplicates.includes(parentObj)) { + break + } + + // Sets eventObject to object that registered the event listener + event.eventObject = parentObj as TresObject + executeEventListeners((parentObj as Record)[eventName], event) + duplicates.push(parentObj) + parentObj = parentObj.parent + } + + // Convert eventName to kebab case and emit event from TresCanvas + const kebabEventName = hyphenate(eventName.slice(2)) as EmitEventName + emit(kebabEventName, { intersection, event }) + } + } + + const { + onClick, + onDblClick, + onContextMenu, + onPointerMove, + onPointerDown, + onPointerUp, + onPointerMissed, + onWheel, + forceUpdate, + } = useRaycaster(objectsWithEvents, context) + + onPointerUp(event => propogateEvent('onPointerUp', event)) + onPointerDown(event => propogateEvent('onPointerDown', event)) + onClick(event => propogateEvent('onClick', event)) + onDblClick(event => propogateEvent('onDoubleClick', event)) + onContextMenu(event => propogateEvent('onContextMenu', event)) + onWheel(event => propogateEvent('onWheel', event)) + + let prevIntersections: Intersection[] = [] + + onPointerMove((event) => { + // Current intersections mapped as meshes + const hits = event.intersections.map(({ object }) => object) + + // Keep Backup of new intersections incase we overwrite due to a pointer out or leave event + const newIntersections = event.intersections as unknown as Intersection[] + + // Previously intersected mesh is no longer intersected, fire onPointerLeave + prevIntersections.forEach(({ object: hit }) => { + if ( + !hits.includes(hit as unknown as Object3D) + ) { + event.intersections = prevIntersections + propogateEvent('onPointerLeave', event) + propogateEvent('onPointerOut', event) + } + }) + + // Reset intersections to newIntersections + event.intersections = newIntersections + + // Newly intersected mesh is not in the previous intersections, fire onPointerEnter + event.intersections.forEach(({ object: hit }) => { + if (!prevIntersections.includes(hit as unknown as Intersection)) { + propogateEvent('onPointerEnter', event) + propogateEvent('onPointerOver', event) + } + }) + + // Fire onPointerMove for all intersected objects + propogateEvent('onPointerMove', event) + + // Update previous intersections + prevIntersections = event.intersections as unknown as Intersection[] + }) + + /** + * We need to track pointer missed objects separately + * since they will not be a part of the raycaster intersection + */ + const pointerMissedObjects: TresObject[] = [] + onPointerMissed((event: TresEvent) => { + // Flag that is set to true when the stopProgatingFn is called + const stopPropagatingFn = () => (event.stopPropagating = true) + event.stopPropagation = stopPropagatingFn + + pointerMissedObjects.forEach((object: TresObject) => { + if (event.stopPropagating) { return } + + // Set eventObject to object that registered the event + event.eventObject = object + + executeEventListeners(object.onPointerMissed, event) + }) + // Emit pointer-missed from TresCanvas + emit('pointer-missed', { event }) + }) + + function registerObject(maybeTresObject: unknown) { + if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject)) { + objectsWithEvents.push(maybeTresObject as TresInstance) + } + } + + function deregisterObject(maybeTresObject: unknown) { + if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject)) { + const index = objectsWithEvents.indexOf(maybeTresObject as TresInstance) + if (index > -1) { + objectsWithEvents.splice(index, 1) + } + } + } + + function registerPointerMissedObject(maybeTresObject: unknown) { + if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject) && maybeTresObject.onPointerMissed) { + pointerMissedObjects.push(maybeTresObject) + } + } + + function deregisterPointerMissedObject(maybeTresObject: unknown) { + if (is.tresObject(maybeTresObject) && is.object3D(maybeTresObject)) { + const index = pointerMissedObjects.indexOf(maybeTresObject) + if (index > -1) { + pointerMissedObjects.splice(index, 1) + } + } + } + + // Attach methods to tres context + context.eventManager = { + forceUpdate, + registerObject, + deregisterObject, + registerPointerMissedObject, + deregisterPointerMissedObject, + } + + return { + forceUpdate, + registerObject, + deregisterObject, + registerPointerMissedObject, + deregisterPointerMissedObject, + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/useTresReady.ts b/src/utils/renderers/tres/useTresReady.ts new file mode 100644 index 00000000..be54a6fa --- /dev/null +++ b/src/utils/renderers/tres/useTresReady.ts @@ -0,0 +1,65 @@ +import type { TresContext } from './useTresContextProvider' +import { useTresContext } from './useTresContextProvider' +// import { createReadyEventHook } from './createReadyEventHook' + +const ctxToUseTresReady = new WeakMap< + TresContext, + ReturnType +>() + +export function useTresReady(ctx?: TresContext) { + ctx = ctx || useTresContext() + if (ctxToUseTresReady.has(ctx)) { + return ctxToUseTresReady.get(ctx)! + } + + const MAX_READY_WAIT_MS = 100 + const start = Date.now() + + // NOTE: Consider Tres to be "ready" if either is true: + // - MAX_READY_WAIT_MS has passed (assume Tres is intentionally degenerate) + // - Tres is not degenerate + // - A renderer exists + // - A DOM element exists + // - The DOM element's height/width is not 0 + const getTresIsReady = () => { + if (Date.now() - start >= MAX_READY_WAIT_MS) { + return true + } + else { + const renderer = ctx.renderer.value + const domElement = renderer?.domElement || { width: 0, height: 0 } + return !!(renderer && domElement.width > 0 && domElement.height > 0) + } + } + + const args = ctx as TresContext + function createReadyEventHook() { + console.log('createReadyEventHook', ...arguments); + return { + on(a) { + setTimeout(a, 12); + console.log('createReadyEventHook.on', a); + }, + cancel() { + console.log('createReadyEventHook.cancel'); + } + } + } + const result = createReadyEventHook(getTresIsReady, args) + ctxToUseTresReady.set(ctx, result) + + return result +} + +export function onTresReady(fn: (ctx: TresContext) => void) { + const ctx = useTresContext() + if (ctx) { + if (ctxToUseTresReady.has(ctx)) { + return ctxToUseTresReady.get(ctx)!.on(fn) + } + else { + return useTresReady(ctx).on(fn) + } + } +} \ No newline at end of file diff --git a/src/utils/renderers/tres/utils.ts b/src/utils/renderers/tres/utils.ts deleted file mode 100644 index 7bbfe5a9..00000000 --- a/src/utils/renderers/tres/utils.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { DoubleSide, MeshBasicMaterial, Vector3 } from 'three' -import type { Mesh, Object3D, Scene } from 'three' - -export function toSetMethodName(key: string) { - return `set${key[0].toUpperCase()}${key.slice(1)}` -} - -export const merge = (target: any, source: any) => { - // Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties - for (const key of Object.keys(source)) { - if (source[key] instanceof Object) { - Object.assign(source[key], merge(target[key], source[key])) - } - } - - // Join `target` and modified `source` - Object.assign(target || {}, source) - return target -} - -const HTML_TAGS - = 'html,body,base,head,link,meta,style,title,address,article,aside,footer,' - + 'header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' - + 'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' - + 'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' - + 'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' - + 'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' - + 'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' - + 'option,output,progress,select,textarea,details,dialog,menu,' - + 'summary,template,blockquote,iframe,tfoot' - -export const isHTMLTag = /* #__PURE__ */ makeMap(HTML_TAGS) - -export function isDOMElement(obj: any): obj is HTMLElement { - return obj && obj.nodeType === 1 -} - -export function kebabToCamel(str: string) { - return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) -} - -export function makeMap(str: string, expectsLowerCase?: boolean): (key: string) => boolean { - const map: Record = Object.create(null) - const list: Array = str.split(',') - for (let i = 0; i < list.length; i++) { - map[list[i]] = true - } - return expectsLowerCase ? val => !!map[val.toLowerCase()] : val => !!map[val] -} - -export const uniqueBy = (array: T[], iteratee: (value: T) => K): T[] => { - const seen = new Set() - const result: T[] = [] - - for (const item of array) { - const identifier = iteratee(item) - if (!seen.has(identifier)) { - seen.add(identifier) - result.push(item) - } - } - - return result -} - -export const get = (obj: any, path: string | string[]): T | undefined => { - if (!path) { - return undefined - } - - // Regex explained: https://regexr.com/58j0k - const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g) - - return pathArray?.reduce((prevObj, key) => prevObj && prevObj[key], obj) -} - -export const set = (obj: any, path: string | string[], value: any): void => { - // Regex explained: https://regexr.com/58j0k - const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g) - - if (pathArray) { - pathArray.reduce((acc, key, i) => { - if (acc[key] === undefined) { - acc[key] = {} - } - if (i === pathArray.length - 1) { - acc[key] = value - } - return acc[key] - }, obj) - } -} - -export function deepEqual(a: any, b: any): boolean { - if (isDOMElement(a) && isDOMElement(b)) { - const attrsA = a.attributes - const attrsB = b.attributes - - if (attrsA.length !== attrsB.length) { - return false - } - - return Array.from(attrsA).every(({ name, value }) => b.getAttribute(name) === value) - } - // If both are primitives, return true if they are equal - if (a === b) { - return true - } - - // If either of them is null or not an object, return false - if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') { - return false - } - - // Get the keys of both objects - const keysA = Object.keys(a); const keysB = Object.keys(b) - - // If they have different number of keys, they are not equal - if (keysA.length !== keysB.length) { - return false - } - - // Check each key in A to see if it exists in B and its value is the same in both - for (const key of keysA) { - if (!keysB.includes(key) || !deepEqual(a[key], b[key])) { - return false - } - } - - return true -} - -export function deepArrayEqual(arr1: any[], arr2: any[]): boolean { - // If they're not both arrays, return false - if (!Array.isArray(arr1) || !Array.isArray(arr2)) { - return false - } - - // If they don't have the same length, they're not equal - if (arr1.length !== arr2.length) { - return false - } - - // Check each element of arr1 against the corresponding element of arr2 - for (let i = 0; i < arr1.length; i++) { - if (!deepEqual(arr1[i], arr2[i])) { - return false - } - } - - return true -} - -/** - * TypeSafe version of Array.isArray - */ -export const isArray = Array.isArray as (a: any) => a is any[] | readonly any[] - -export function editSceneObject(scene: Scene, objectUuid: string, propertyPath: string[], value: any): void { - // Function to recursively find the object by UUID - const findObjectByUuid = (node: Object3D): Object3D | undefined => { - if (node.uuid === objectUuid) { - return node - } - - for (const child of node.children) { - const found = findObjectByUuid(child) - if (found) { - return found - } - } - - return undefined - } - - // Find the target object - const targetObject = findObjectByUuid(scene) - if (!targetObject) { - console.warn('Object with UUID not found in the scene.') - return - } - - // Traverse the property path to get to the desired property - let currentProperty: any = targetObject - for (let i = 0; i < propertyPath.length - 1; i++) { - if (currentProperty[propertyPath[i]] !== undefined) { - currentProperty = currentProperty[propertyPath[i]] - } - else { - console.warn(`Property path is not valid: ${propertyPath.join('.')}`) - return - } - } - - // Set the new value - const lastProperty = propertyPath[propertyPath.length - 1] - if (currentProperty[lastProperty] !== undefined) { - currentProperty[lastProperty] = value - } - else { - console.warn(`Property path is not valid: ${propertyPath.join('.')}`) - } -} - -export function createHighlightMaterial(): MeshBasicMaterial { - return new MeshBasicMaterial({ - color: 0xA7E6D7, // Highlight color, e.g., yellow - transparent: true, - opacity: 0.2, - depthTest: false, // So the highlight is always visible - side: DoubleSide, // To ensure the highlight is visible from all angles - }) -} -let animationFrameId: number | null = null -export function animateHighlight(highlightMesh: Mesh, startTime: number): void { - const currentTime = Date.now() - const time = (currentTime - startTime) / 1000 // convert to seconds - - // Pulsing effect parameters - const scaleAmplitude = 0.07 // Amplitude of the scale pulsation - const pulseSpeed = 2.5 // Speed of the pulsation - - // Calculate the scale factor with a sine function for pulsing effect - const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time) - - // Apply the scale factor - highlightMesh.scale.set(scaleFactor, scaleFactor, scaleFactor) - - // Update the animation frame ID - animationFrameId = requestAnimationFrame(() => animateHighlight(highlightMesh, startTime)) -} - -export function stopHighlightAnimation(): void { - if (animationFrameId !== null) { - cancelAnimationFrame(animationFrameId) - animationFrameId = null - } -} - -export function createHighlightMesh(object: Object3D): Mesh { - const highlightMaterial = new MeshBasicMaterial({ - color: 0xA7E6D7, // Highlight color, e.g., yellow - transparent: true, - opacity: 0.2, - depthTest: false, // So the highlight is always visible - side: DoubleSide, // To e - }) - // Clone the geometry of the object. You might need a more complex approach - // if the object's geometry is not straightforward. - // @ts-expect-error - const highlightMesh = new HightlightMesh(object.geometry.clone(), highlightMaterial) - - return highlightMesh -} - -export function extractBindingPosition(binding: any): Vector3 { - let observer = binding.value - if (binding.value && binding.value?.isMesh) { - observer = binding.value.position - } - if (Array.isArray(binding.value)) { observer = new Vector3(...observer) } - return observer -} \ No newline at end of file diff --git a/src/utils/renderers/tres/utils/index.ts b/src/utils/renderers/tres/utils/index.ts new file mode 100644 index 00000000..339341cd --- /dev/null +++ b/src/utils/renderers/tres/utils/index.ts @@ -0,0 +1,584 @@ +// import type { nodeOps } from 'src/core/nodeOps' +import type { AttachType, LocalState, TresInstance, TresObject, TresPrimitive } from './../types' +import type { Material, Mesh, Object3D, Texture } from 'three' +// import type { TresContext } from '../composables/useTresContextProvider' +import { DoubleSide, MathUtils, MeshBasicMaterial, Scene, Vector3 } from 'three' +// import { HightlightMesh } from '../devtools/highlight' +import * as is from './is' + +export function toSetMethodName(key: string) { + return `set${key[0].toUpperCase()}${key.slice(1)}` +} + +export const merge = (target: any, source: any) => { + // Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties + for (const key of Object.keys(source)) { + if (source[key] instanceof Object) { + Object.assign(source[key], merge(target[key], source[key])) + } + } + + // Join `target` and modified `source` + Object.assign(target || {}, source) + return target +} + +const HTML_TAGS + = 'html,body,base,head,link,meta,style,title,address,article,aside,footer,' + + 'header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' + + 'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' + + 'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' + + 'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' + + 'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' + + 'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' + + 'option,output,progress,select,textarea,details,dialog,menu,' + + 'summary,template,blockquote,iframe,tfoot' + +export const isHTMLTag = /* #__PURE__ */ makeMap(HTML_TAGS) + +export function isDOMElement(obj: any): obj is HTMLElement { + return obj && obj.nodeType === 1 +} + +export function kebabToCamel(str: string) { + return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) +} + +// CamelCase to kebab-case +const hyphenateRE = /\B([A-Z])/g +export function hyphenate(str: string) { + return str.replace(hyphenateRE, '-$1').toLowerCase() +} + +export function makeMap(str: string, expectsLowerCase?: boolean): (key: string) => boolean { + const map: Record = Object.create(null) + const list: Array = str.split(',') + for (let i = 0; i < list.length; i++) { + map[list[i]] = true + } + return expectsLowerCase ? val => !!map[val.toLowerCase()] : val => !!map[val] +} + +export const uniqueBy = (array: T[], iteratee: (value: T) => K): T[] => { + const seen = new Set() + const result: T[] = [] + + for (const item of array) { + const identifier = iteratee(item) + if (!seen.has(identifier)) { + seen.add(identifier) + result.push(item) + } + } + + return result +} + +export const get = (obj: any, path: string | string[]): T | undefined => { + if (!path) { + return undefined + } + + // Regex explained: https://regexr.com/58j0k + const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g) + + return pathArray?.reduce((prevObj, key) => prevObj && prevObj[key], obj) +} + +export const set = (obj: any, path: string | string[], value: any): void => { + // Regex explained: https://regexr.com/58j0k + const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g) + + if (pathArray) { + pathArray.reduce((acc, key, i) => { + if (acc[key] === undefined) { + acc[key] = {} + } + if (i === pathArray.length - 1) { + acc[key] = value + } + return acc[key] + }, obj) + } +} + +export function deepEqual(a: any, b: any): boolean { + if (isDOMElement(a) && isDOMElement(b)) { + const attrsA = a.attributes + const attrsB = b.attributes + + if (attrsA.length !== attrsB.length) { + return false + } + + return Array.from(attrsA).every(({ name, value }) => b.getAttribute(name) === value) + } + // If both are primitives, return true if they are equal + if (a === b) { + return true + } + + // If either of them is null or not an object, return false + if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') { + return false + } + + // Get the keys of both objects + const keysA = Object.keys(a); const keysB = Object.keys(b) + + // If they have different number of keys, they are not equal + if (keysA.length !== keysB.length) { + return false + } + + // Check each key in A to see if it exists in B and its value is the same in both + for (const key of keysA) { + if (!keysB.includes(key) || !deepEqual(a[key], b[key])) { + return false + } + } + + return true +} + +export function deepArrayEqual(arr1: any[], arr2: any[]): boolean { + // If they're not both arrays, return false + if (!Array.isArray(arr1) || !Array.isArray(arr2)) { + return false + } + + // If they don't have the same length, they're not equal + if (arr1.length !== arr2.length) { + return false + } + + // Check each element of arr1 against the corresponding element of arr2 + for (let i = 0; i < arr1.length; i++) { + if (!deepEqual(arr1[i], arr2[i])) { + return false + } + } + + return true +} + +/** + * TypeSafe version of Array.isArray + */ +export const isArray = Array.isArray as (a: any) => a is any[] | readonly any[] + +export function editSceneObject(scene: Scene, objectUuid: string, propertyPath: string[], value: any): void { + // Function to recursively find the object by UUID + const findObjectByUuid = (node: Object3D): Object3D | undefined => { + if (node.uuid === objectUuid) { + return node + } + + for (const child of node.children) { + const found = findObjectByUuid(child) + if (found) { + return found + } + } + + return undefined + } + + // Find the target object + const targetObject = findObjectByUuid(scene) + if (!targetObject) { + console.warn('Object with UUID not found in the scene.') + return + } + + // Traverse the property path to get to the desired property + let currentProperty: any = targetObject + for (let i = 0; i < propertyPath.length - 1; i++) { + if (currentProperty[propertyPath[i]] !== undefined) { + currentProperty = currentProperty[propertyPath[i]] + } + else { + console.warn(`Property path is not valid: ${propertyPath.join('.')}`) + return + } + } + + // Set the new value + const lastProperty = propertyPath[propertyPath.length - 1] + if (currentProperty[lastProperty] !== undefined) { + currentProperty[lastProperty] = value + } + else { + console.warn(`Property path is not valid: ${propertyPath.join('.')}`) + } +} + +export function createHighlightMaterial(): MeshBasicMaterial { + return new MeshBasicMaterial({ + color: 0xA7E6D7, // Highlight color, e.g., yellow + transparent: true, + opacity: 0.2, + depthTest: false, // So the highlight is always visible + side: DoubleSide, // To ensure the highlight is visible from all angles + }) +} +let animationFrameId: number | null = null +export function animateHighlight(highlightMesh: Mesh, startTime: number): void { + const currentTime = Date.now() + const time = (currentTime - startTime) / 1000 // convert to seconds + + // Pulsing effect parameters + const scaleAmplitude = 0.07 // Amplitude of the scale pulsation + const pulseSpeed = 2.5 // Speed of the pulsation + + // Calculate the scale factor with a sine function for pulsing effect + const scaleFactor = 1 + scaleAmplitude * Math.sin(pulseSpeed * time) + + // Apply the scale factor + highlightMesh.scale.set(scaleFactor, scaleFactor, scaleFactor) + + // Update the animation frame ID + animationFrameId = requestAnimationFrame(() => animateHighlight(highlightMesh, startTime)) +} + +export function stopHighlightAnimation(): void { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } +} + +export function createHighlightMesh(object: TresObject): Mesh { + const highlightMaterial = new MeshBasicMaterial({ + color: 0xA7E6D7, // Highlight color, e.g., yellow + transparent: true, + opacity: 0.2, + depthTest: false, // So the highlight is always visible + side: DoubleSide, // To e + }) + // Clone the geometry of the object. You might need a more complex approach + // if the object's geometry is not straightforward. + const highlightMesh = new HightlightMesh(object.geometry.clone(), highlightMaterial) + + return highlightMesh +} + +export function extractBindingPosition(binding: any): Vector3 { + let observer = binding.value + if (binding.value && binding.value?.isMesh) { + observer = binding.value.position + } + if (Array.isArray(binding.value)) { observer = new Vector3(...observer) } + return observer +} + +function hasMap(material: Material): material is Material & { map: Texture | null } { + return 'map' in material +} + +export function disposeMaterial(material: Material): void { + if (hasMap(material) && material.map) { + material.map.dispose() + } + + material.dispose() +} + +export function disposeObject3D(object: TresObject): void { + if (object.parent) { + object.removeFromParent?.() + } + delete object.__tres + // Clone the children array to safely iterate + const children = [...object.children] + children.forEach(child => disposeObject3D(child)) + + if (object instanceof Scene) { + // Optionally handle Scene-specific cleanup + } + else { + const mesh = object as unknown as Partial + if (object) { + object.dispose?.() + } + if (mesh.geometry) { + mesh.geometry.dispose() + delete mesh.geometry + } + + if (Array.isArray(mesh.material)) { + mesh.material.forEach(material => disposeMaterial(material)) + delete mesh.material + } + else if (mesh.material) { + disposeMaterial(mesh.material) + delete mesh.material + } + } +} + +/** + * Like Array.filter, but modifies the array in place. + * @param array - Array to modify + * @param callbackFn - A function called for each element of the array. It should return a truthy value to keep the element in the array. + */ +export function filterInPlace(array: T[], callbackFn: (element: T, index: number) => unknown) { + let i = 0 + for (let ii = 0; ii < array.length; ii++) { + if (callbackFn(array[ii], ii)) { + array[i] = array[ii] + i++ + } + } + array.length = i + return array +} + +export function resolve(obj: Record, key: string) { + let target = obj + if (key.includes('-')) { + const entries = key.split('-') + let currKey = entries.shift() as string + while (target && entries.length) { + if (!(currKey in target)) { + currKey = joinAsCamelCase(currKey, entries.shift() as string) + } + else { + target = target[currKey] + currKey = entries.shift() as string + } + } + return { target, key: joinAsCamelCase(currKey, ...entries) } + } + else { + return { target, key } + } +} + +function joinAsCamelCase(...strings: string[]): string { + return strings.map((s, i) => i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1)).join('') +} + +// Checks if a dash-cased string ends with an integer +const INDEX_REGEX = /-\d+$/ + +export function attach(parent: TresInstance, child: TresInstance, type: AttachType) { + if (is.str(type)) { + // NOTE: If attaching into an array (foo-0), create one + if (INDEX_REGEX.test(type)) { + const typeWithoutTrailingIndex = type.replace(INDEX_REGEX, '') + const { target, key } = resolve(parent, typeWithoutTrailingIndex) + if (!Array.isArray(target[key])) { + // NOTE: Create the array and augment it with a function + // that resets the original value if the array is empty or + // `[undefined, undefined, ...]`. The function will be run + // every time an element is `detach`ed from the array. + const previousAttach = target[key] + const augmentedArray: any[] & { __tresDetach?: () => void } = [] + augmentedArray.__tresDetach = () => { + if (augmentedArray.every(v => is.und(v))) { + target[key] = previousAttach + } + } + target[key] = augmentedArray + } + } + + const { target, key } = resolve(parent, type) + child.__tres.previousAttach = target[key] + target[key] = unboxTresPrimitive(child) + } + else { + child.__tres.previousAttach = type(parent, child) + } +} + +export function detach(parent: any, child: TresInstance, type: AttachType) { + if (is.str(type)) { + const { target, key } = resolve(parent, type) + const previous = child.__tres.previousAttach + // When the previous value was undefined, it means the value was never set to begin with + if (previous === undefined) { + delete target[key] + } + // NOTE: If the previous value was not an array, and `attach` turned it into an array + // then it also set `__tresOnArrayElementsUndefined`. Check for it and revert + // Otherwise set the previous value + else { + target[key] = previous + } + + if ('__tresDetach' in target) { target.__tresDetach() } + } + else { + child.__tres?.previousAttach?.(parent, child) + } + delete child.__tres?.previousAttach +} + +export function prepareTresInstance(obj: T, state: Partial, context: TresContext): TresInstance { + const instance = obj as unknown as TresInstance + + instance.__tres = { + type: 'unknown', + eventCount: 0, + root: context, + handlers: {}, + memoizedProps: {}, + objects: [], + parent: null, + previousAttach: null, + ...state, + } + + if (!instance.__tres.attach) { + if (instance.isMaterial) { instance.__tres.attach = 'material' } + else if (instance.isBufferGeometry) { instance.__tres.attach = 'geometry' } + else if (instance.isFog) { instance.__tres.attach = 'fog' } + } + + return instance +} + +export function invalidateInstance(instance: TresObject) { + const ctx = instance?.__tres?.root + + if (!ctx) { return } + + if (ctx.render && ctx.render.canBeInvalidated.value) { + ctx.invalidate() + } +} + +export function noop(fn: string): any { + // eslint-disable-next-line ts/no-unused-expressions + fn +} + +export function setPixelRatio(renderer: { setPixelRatio?: (dpr: number) => void, getPixelRatio?: () => number }, systemDpr: number, userDpr?: number | [number, number]) { + // NOTE: Optional `setPixelRatio` allows this function to accept + // THREE renderers like SVGRenderer. + if (!is.fun(renderer.setPixelRatio)) { return } + + let newDpr = 0 + + if (userDpr && is.arr(userDpr) && userDpr.length >= 2) { + const [min, max] = userDpr + newDpr = MathUtils.clamp(systemDpr, min, max) + } + else if (is.num(userDpr)) { newDpr = userDpr } + else { newDpr = systemDpr } + + // NOTE: Don't call `setPixelRatio` unless both: + // - the dpr value has changed + // - the renderer has `setPixelRatio`; this check allows us to pass any THREE renderer + if (newDpr !== renderer.getPixelRatio?.()) { renderer.setPixelRatio(newDpr) } +} + +export function setPrimitiveObject( + newObject: TresObject, + primitive: TresPrimitive, + setTarget: (object: TresObject) => void, + nodeOpsFns: Pick, 'patchProp' | 'insert' | 'remove'>, + context: TresContext, +) { + // NOTE: copy added/attached Vue children + // We need to insert `objects` into `newObject` later. + // In the meantime, `remove(primitive)` will alter + // the array, so make a copy. + const objectsToAttach = [...primitive.__tres.objects] + + const oldObject = unboxTresPrimitive(primitive) + newObject = unboxTresPrimitive(newObject) + if (oldObject === newObject) { return true } + + const newInstance: TresInstance = prepareTresInstance(newObject, primitive.__tres ?? {}, context) + + // NOTE: `remove`ing `oldInstance` will modify `parent` and `memoizedProps`. + // Copy before removing. + const parent = primitive.parent ?? primitive.__tres.parent ?? null + const propsToPatch = { ...primitive.__tres.memoizedProps } + // NOTE: `object` is a reference to `oldObject` and not to be patched. + delete propsToPatch.object + + // NOTE: detach/deactivate added/attached Vue children, but don't + // otherwise alter them and don't recurse. + for (const obj of objectsToAttach) { + doRemoveDetach(obj, context) + doRemoveDeregister(obj, context) + } + oldObject.__tres.objects = [] + + nodeOpsFns.remove(primitive) + + for (const [key, value] of Object.entries(propsToPatch)) { + nodeOpsFns.patchProp(newInstance, key, newInstance[key], value) + } + + setTarget(newObject) + nodeOpsFns.insert(primitive, parent) + + // NOTE: insert added/attached Vue children + for (const obj of objectsToAttach) { + nodeOpsFns.insert(obj, primitive) + } + + return true +} + +export function unboxTresPrimitive(maybePrimitive: T): T | TresInstance { + if (is.tresPrimitive(maybePrimitive)) { + // NOTE: + // `primitive` has-a THREE object. Multiple `primitive`s can have + // the same THREE object. We want to allow the same THREE object + // to be inserted in the graph in multiple places, where THREE supports + // that, e.g., materials and geometries. + // But __tres (`LocalState`) only allows for a single parent. + // So: copy `__tres` to the object when unboxing. + maybePrimitive.object.__tres = maybePrimitive.__tres + return maybePrimitive.object + } + else { + return maybePrimitive + } +} + +export function doRemoveDetach(node: TresObject, context: TresContext) { + // NOTE: Remove `node` from its parent's __tres parent/objects graph + const parent = node.__tres?.parent || context.scene.value + if (node.__tres) { node.__tres.parent = null } + if (parent && parent.__tres && 'objects' in parent.__tres) { + filterInPlace(parent.__tres.objects, obj => obj !== node) + } + + // NOTE: THREE.removeFromParent removes `node` from + // `parent.children`. + if (node.__tres?.attach) { + detach(parent, node as TresInstance, node.__tres.attach) + } + else { + // NOTE: In case this is a primitive, we added the :object, not + // the primitive. So we "unbox" here to remove the :object. + // If not a primitive, unboxing returns the argument. + node.parent?.remove?.(unboxTresPrimitive(node)) + // NOTE: THREE doesn't set `node.parent` when removing `node`. + // We will do that here to properly maintain the parent/children + // graph as a source of truth. + node.parent = null + } +} + +export function doRemoveDeregister(node: TresObject, context: TresContext) { + // TODO: Refactor as `context.deregister`? + // That would eliminate `context.deregisterCamera`. + node.traverse?.((child: TresObject) => { + context.deregisterCamera(child) + // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject) + context.eventManager?.deregisterPointerMissedObject(child) + }) + + // NOTE: Deregister `node` + context.deregisterCamera(node) + /* deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */ + invalidateInstance(node as TresObject) +} \ No newline at end of file diff --git a/src/utils/renderers/tres/utils/is.ts b/src/utils/renderers/tres/utils/is.ts new file mode 100644 index 00000000..72781018 --- /dev/null +++ b/src/utils/renderers/tres/utils/is.ts @@ -0,0 +1,68 @@ +import type { TresObject, TresPrimitive } from './../types' +import type { BufferGeometry, Camera, Fog, Light, Material, Object3D, Scene } from 'three' + +export function und(u: unknown) { + return typeof u === 'undefined' +} + +export function arr(u: unknown) { + return Array.isArray(u) +} + +export function num(u: unknown): u is number { + return typeof u === 'number' +} + +export function str(u: unknown): u is string { + return typeof u === 'string' +} + +export function bool(u: unknown): u is boolean { + return u === true || u === false +} + +export function fun(u: unknown): u is (...args: any[]) => any { + return typeof u === 'function' +} + +export function obj(u: unknown): u is Record { + return u === Object(u) && !arr(u) && !fun(u) +} + +export function object3D(u: unknown): u is Object3D { + return obj(u) && ('isObject3D' in u) && !!(u.isObject3D) +} + +export function camera(u: unknown): u is Camera { + return obj(u) && 'isCamera' in u && !!(u.isCamera) +} + +export function bufferGeometry(u: unknown): u is BufferGeometry { + return obj(u) && 'isBufferGeometry' in u && !!(u.isBufferGeometry) +} + +export function material(u: unknown): u is Material { + return obj(u) && 'isMaterial' in u && !!(u.isMaterial) +} + +export function light(u: unknown): u is Light { + return obj(u) && 'isLight' in u && !!(u.isLight) +} + +export function fog(u: unknown): u is Fog { + return obj(u) && 'isFog' in u && !!(u.isFog) +} + +export function scene(u: unknown): u is Scene { + return obj(u) && 'isScene' in u && !!(u.isScene) +} + +export function tresObject(u: unknown): u is TresObject { + // NOTE: TresObject is currently defined as + // TresObject3D | THREE.BufferGeometry | THREE.Material | THREE.Fog + return object3D(u) || bufferGeometry(u) || material(u) || fog(u) +} + +export function tresPrimitive(u: unknown): u is TresPrimitive { + return obj(u) && !!(u.isPrimitive) +} \ No newline at end of file diff --git a/src/utils/renderers/tres/utils/normalize.ts b/src/utils/renderers/tres/utils/normalize.ts new file mode 100644 index 00000000..899cd9ce --- /dev/null +++ b/src/utils/renderers/tres/utils/normalize.ts @@ -0,0 +1,42 @@ +import type { ColorRepresentation } from 'three' +import { Color, Vector3 } from 'three' + +export type SizeFlexibleParams = + | number[] + | { + width: number + height: number + } + +export interface Vector2PropInterface { + x?: number + y?: number + width?: number + height?: number +} + +export interface Vector3PropInterface extends Vector2PropInterface { + z?: number +} + +export type VectorFlexibleParams = Vector3 | number[] | Vector3PropInterface | number + +export function normalizeVectorFlexibleParam(value: VectorFlexibleParams): Array { + if (typeof value === 'number') { + return [value, value, value] + } + if (value instanceof Vector3) { + return [value.x, value.y, value.z] + } + return value as Array +} + +export function normalizeColor(value: Color | Array | string | number | ColorRepresentation) { + if (value instanceof Color) { + return value + } + if (Array.isArray(value)) { + return new Color(...value) + } + return new Color(value as ColorRepresentation) +} \ No newline at end of file diff --git a/src/utils/renderers/tres/vue.ts b/src/utils/renderers/tres/vue.ts new file mode 100644 index 00000000..4ef76a66 --- /dev/null +++ b/src/utils/renderers/tres/vue.ts @@ -0,0 +1,69 @@ +import { isTag } from '@/utils/helpers/-private'; +import { cell } from '@lifeart/gxt'; + +export const ref = cell; +export function unref(el: any) { + if (isTag(el)) { + console.log('unref-tag', el); + return el.value; + } + console.log('unref', el); + return el; +} +export function useFps() { + console.log('useFps', ...arguments); +} +export function unrefElement(node) { + console.log('unrefElement', node); + return node; +} +export function useMemory() { + console.log('useMemory', ...arguments); + return { + isSupported: false, + memory: 100, + } +} + +export function useRafFn() { + console.log('useRafFn', ...arguments); + return { + pause() { + debugger; + } + } +} +export function computed(fn: any) { + console.log('computed'); + return { + get value() { + return fn(); + } + } +} +export function inject() { + console.log('inject', ...arguments); +} +export function provide() { + console.log('provide', ...arguments); +} +export function readonly(v) { + console.log('readOnly', ...arguments); + return v; +} +export function onUnmounted(fn: any) { + console.log('onUnmounted', fn); +} +export function watchEffect(fn: any) { + console.log('watchEffect', fn); + // fn(); +} +export type MaybeRef = any; + +export function shallowRef(el) { + console.log('shallowRef', ...arguments); + return el; +} +export function watch() { + console.log('watch', ...arguments); +} From 68f7c4e393959eca0dce1250cf2aa6655277ecd9 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Fri, 17 Jan 2025 19:23:02 +0300 Subject: [PATCH 5/5] + --- src/utils/renderers/tres/TresCanvas.ts | 6 ++---- src/utils/renderers/tres/useTresContextProvider.ts | 9 +++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/utils/renderers/tres/TresCanvas.ts b/src/utils/renderers/tres/TresCanvas.ts index 97bc1ec2..88dcc6a0 100644 --- a/src/utils/renderers/tres/TresCanvas.ts +++ b/src/utils/renderers/tres/TresCanvas.ts @@ -54,6 +54,8 @@ export function TresCanvas(this: Component) { canvasNode.style.top = '0'; canvasNode.style.left = '0'; canvasNode.style.pointerEvents = 'auto'; + const existingCanvas = canvasNode; + const scene = new Scene(); const $slots = $_GET_SLOTS(this, arguments); @@ -61,9 +63,6 @@ export function TresCanvas(this: Component) { const root = {} as Root; provideContext(root, RENDERING_CONTEXT, api); requestAnimationFrame(() => { - const existingCanvas = canvasNode; - const scene = new Scene(); - const nodes = $slots.default(root); nodes.forEach((node: unknown) => { @@ -83,7 +82,6 @@ export function TresCanvas(this: Component) { registerCamera, camera, cameras, deregisterCamera }) - const addDefaultCamera = () => { const camera = new PerspectiveCamera( 45, diff --git a/src/utils/renderers/tres/useTresContextProvider.ts b/src/utils/renderers/tres/useTresContextProvider.ts index 93384988..5f77f0a0 100644 --- a/src/utils/renderers/tres/useTresContextProvider.ts +++ b/src/utils/renderers/tres/useTresContextProvider.ts @@ -185,11 +185,12 @@ export function useTresContextProvider({ ctx.loop.register(() => { - debugger; - if (camera && render.frames > 0) { - debugger; + // debugger; + render.frames = 1 + if (camera.value) { + // debugger; renderer.render(scene, camera.value) - emit('render', ctx.renderer) + // emit('render', ctx.renderer) } // Reset priority