diff --git a/web/src/main/kotlin/world/phantasmal/web/Main.kt b/web/src/main/kotlin/world/phantasmal/web/Main.kt index 50257d70..2ae42c4e 100644 --- a/web/src/main/kotlin/world/phantasmal/web/Main.kt +++ b/web/src/main/kotlin/world/phantasmal/web/Main.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import mu.KotlinLoggingConfiguration import mu.KotlinLoggingLevel +import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.PopStateEvent import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposer @@ -72,9 +73,10 @@ private fun init(): Disposable { return disposer } -private fun createThreeRenderer(): DisposableThreeRenderer = +private fun createThreeRenderer(canvas: HTMLCanvasElement): DisposableThreeRenderer = object : TrackedDisposable(), DisposableThreeRenderer { override val renderer = WebGLRenderer(obj { + this.canvas = canvas antialias = true alpha = true }) diff --git a/web/src/main/kotlin/world/phantasmal/web/application/Application.kt b/web/src/main/kotlin/world/phantasmal/web/application/Application.kt index cf73af9b..4898a0a9 100644 --- a/web/src/main/kotlin/world/phantasmal/web/application/Application.kt +++ b/web/src/main/kotlin/world/phantasmal/web/application/Application.kt @@ -3,6 +3,7 @@ package world.phantasmal.web.application import kotlinx.browser.document import kotlinx.coroutines.CoroutineScope import org.w3c.dom.DragEvent +import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLElement import org.w3c.dom.events.Event import org.w3c.dom.events.KeyboardEvent @@ -27,7 +28,7 @@ class Application( rootElement: HTMLElement, assetLoader: AssetLoader, applicationUrl: ApplicationUrl, - createThreeRenderer: () -> DisposableThreeRenderer, + createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, ) : DisposableContainer() { init { addDisposables( diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/InputManager.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/InputManager.kt new file mode 100644 index 00000000..04616efb --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/InputManager.kt @@ -0,0 +1,12 @@ +package world.phantasmal.web.core.rendering + +/** + * Manages user input such as pointer and keyboard events. + */ +interface InputManager { + fun setSize(width: Double, height: Double) + + fun resetCamera() + + fun beforeRender() +} diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt new file mode 100644 index 00000000..84c508a3 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/OrbitalCameraInputManager.kt @@ -0,0 +1,77 @@ +package world.phantasmal.web.core.rendering + +import org.w3c.dom.HTMLCanvasElement +import world.phantasmal.core.disposable.TrackedDisposable +import world.phantasmal.web.externals.three.* +import world.phantasmal.webui.obj +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max + +class OrbitalCameraInputManager( + canvas: HTMLCanvasElement, + private val camera: Camera, + position: Vector3, + screenSpacePanning: Boolean, +) : TrackedDisposable(), InputManager { + private val controls = OrbitControls(camera, canvas) + + var enabled: Boolean + get() = controls.enabled + set(enabled) { + controls.enabled = enabled + } + + init { + controls.mouseButtons = obj { + LEFT = MOUSE.PAN + MIDDLE = MOUSE.DOLLY + RIGHT = MOUSE.ROTATE + } + + camera.position.copy(position) + controls.screenSpacePanning = screenSpacePanning + controls.update() + controls.saveState() + } + + override fun internalDispose() { + controls.dispose() + super.internalDispose() + } + + fun setTarget(target: Vector3) { + controls.target.copy(target) + controls.update() + } + + override fun resetCamera() { + controls.reset() + } + + override fun setSize(width: Double, height: Double) { + if (width == 0.0 || height == 0.0) return + + if (camera is PerspectiveCamera) { + camera.aspect = width / height + camera.updateProjectionMatrix() + } else if (camera is OrthographicCamera) { + camera.left = -floor(width / 2) + camera.right = ceil(width / 2) + camera.top = floor(height / 2) + camera.bottom = -ceil(height / 2) + camera.updateProjectionMatrix() + } + + controls.update() + } + + override fun beforeRender() { + if (camera is PerspectiveCamera) { + val distance = camera.position.distanceTo(controls.target) + camera.near = distance / 100 + camera.far = max(2_000.0, 10 * distance) + camera.updateProjectionMatrix() + } + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/RenderContext.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/RenderContext.kt new file mode 100644 index 00000000..0253ae19 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/RenderContext.kt @@ -0,0 +1,30 @@ +package world.phantasmal.web.core.rendering + +import org.w3c.dom.HTMLCanvasElement +import world.phantasmal.core.disposable.TrackedDisposable +import world.phantasmal.web.externals.three.* + +open class RenderContext( + val canvas: HTMLCanvasElement, + val camera: Camera, +) : TrackedDisposable() { + private val light = HemisphereLight( + skyColor = 0xffffff, + groundColor = 0x505050, + intensity = 1.0 + ) + private val lightHolder = Group().add(light) + + var width = 0.0 + var height = 0.0 + + val scene: Scene = + Scene().apply { + background = Color(0x181818) + add(lightHolder) + } + + fun pointerPosToDeviceCoords(pos: Vector2) { + pos.set((pos.x / width) * 2 - 1, (pos.y / height) * -2 + 1) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt index 7af0ba31..923e7ce2 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt @@ -1,16 +1,12 @@ package world.phantasmal.web.core.rendering +import kotlinx.browser.document import kotlinx.browser.window import mu.KotlinLogging import org.w3c.dom.HTMLCanvasElement import world.phantasmal.core.disposable.Disposable -import world.phantasmal.core.disposable.disposable -import world.phantasmal.web.externals.three.* import world.phantasmal.webui.DisposableContainer -import world.phantasmal.webui.obj -import kotlin.math.ceil import kotlin.math.floor -import kotlin.math.max import world.phantasmal.web.externals.three.Renderer as ThreeRenderer private val logger = KotlinLogging.logger {} @@ -19,52 +15,16 @@ interface DisposableThreeRenderer : Disposable { val renderer: ThreeRenderer } -abstract class Renderer( - createThreeRenderer: () -> DisposableThreeRenderer, - val camera: Camera, -) : DisposableContainer() { - private val threeRenderer: ThreeRenderer = addDisposable(createThreeRenderer()).renderer - private val light = HemisphereLight( - skyColor = 0xffffff, - groundColor = 0x505050, - intensity = 1.0 - ) - private val lightHolder = Group().add(light) +abstract class Renderer : DisposableContainer() { + protected abstract val context: RenderContext + protected abstract val threeRenderer: ThreeRenderer + protected abstract val inputManager: InputManager + + val canvas: HTMLCanvasElement get() = context.canvas private var rendering = false private var animationFrameHandle: Int = 0 - protected var width = 0.0 - private set - protected var height = 0.0 - private set - - val canvas: HTMLCanvasElement = - threeRenderer.domElement.apply { - tabIndex = 0 - style.outline = "none" - } - - val scene: Scene = - Scene().apply { - background = Color(0x181818) - add(lightHolder) - } - - lateinit var controls: OrbitControls - - open fun initializeControls() { - controls = OrbitControls(camera, canvas).apply { - mouseButtons = obj { - LEFT = MOUSE.PAN - MIDDLE = MOUSE.DOLLY - RIGHT = MOUSE.ROTATE - } - - addDisposable(disposable { dispose() }) - } - } - fun startRendering() { logger.trace { "${this::class.simpleName} - start rendering." } @@ -81,46 +41,23 @@ abstract class Renderer( window.cancelAnimationFrame(animationFrameHandle) } - fun resetCamera() { - controls.reset() - } - open fun setSize(width: Double, height: Double) { if (width == 0.0 || height == 0.0) return - this.width = width - this.height = height - canvas.width = floor(width).toInt() - canvas.height = floor(height).toInt() + context.width = width + context.height = height + context.canvas.width = floor(width).toInt() + context.canvas.height = floor(height).toInt() + threeRenderer.setSize(width, height) - if (camera is PerspectiveCamera) { - camera.aspect = width / height - camera.updateProjectionMatrix() - } else if (camera is OrthographicCamera) { - camera.left = -floor(width / 2) - camera.right = ceil(width / 2) - camera.top = floor(height / 2) - camera.bottom = -ceil(height / 2) - camera.updateProjectionMatrix() - } - - controls.update() - } - - fun pointerPosToDeviceCoords(pos: Vector2) { - pos.set((pos.x / width) * 2 - 1, (pos.y / height) * -2 + 1) + inputManager.setSize(width, height) } protected open fun render() { - if (camera is PerspectiveCamera) { - val distance = camera.position.distanceTo(controls.target) - camera.near = distance / 100 - camera.far = max(2_000.0, 10 * distance) - camera.updateProjectionMatrix() - } + inputManager.beforeRender() - threeRenderer.render(scene, camera) + threeRenderer.render(context.scene, context.camera) } private fun renderLoop() { @@ -134,4 +71,12 @@ abstract class Renderer( } } } + + companion object { + fun createCanvas(): HTMLCanvasElement = + (document.createElement("CANVAS") as HTMLCanvasElement).apply { + tabIndex = 0 + style.outline = "none" + } + } } diff --git a/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt b/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt index 1ae52824..0580f657 100644 --- a/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt +++ b/web/src/main/kotlin/world/phantasmal/web/externals/three/three.kt @@ -66,6 +66,11 @@ external class Vector3( */ fun sub(v: Vector3): Vector3 + /** + * Sets this vector to a - b. + */ + fun subVectors(a: Vector3, b: Vector3): Vector3 + /** * Multiplies this vector by scalar s. */ @@ -83,6 +88,8 @@ external class Vector3( fun length(): Double + fun normalize(): Vector3 + /** * Sets this vector to cross product of itself and [v]. */ @@ -161,6 +168,8 @@ external class Face3( external class Plane(normal: Vector3 = definedExternally, constant: Double = definedExternally) { fun set(normal: Vector3, constant: Double): Plane + fun setFromNormalAndCoplanarPoint(normal: Vector3, point: Vector3): Plane + fun projectPoint(point: Vector3, target: Vector3): Vector3 } open external class EventDispatcher @@ -174,6 +183,10 @@ external interface Renderer { } external interface WebGLRendererParameters { + /** + * A Canvas where the renderer draws its output. + */ + var canvas: HTMLCanvasElement /* HTMLCanvasElement | OffscreenCanvas */ var alpha: Boolean var premultipliedAlpha: Boolean var antialias: Boolean @@ -324,7 +337,9 @@ external class Scene : Object3D { var background: dynamic /* null | Color | Texture | WebGLCubeRenderTarget */ } -open external class Camera : Object3D +open external class Camera : Object3D { + fun getWorldDirection(target: Vector3): Vector3 +} external class PerspectiveCamera( fov: Double = definedExternally, diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt index c60f4569..5934672a 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt @@ -1,6 +1,7 @@ package world.phantasmal.web.questEditor import kotlinx.coroutines.CoroutineScope +import org.w3c.dom.HTMLCanvasElement import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.loading.AssetLoader @@ -10,9 +11,7 @@ import world.phantasmal.web.questEditor.controllers.* import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.QuestLoader -import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager import world.phantasmal.web.questEditor.rendering.QuestRenderer -import world.phantasmal.web.questEditor.rendering.UserInputManager import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AssemblyEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore @@ -23,7 +22,7 @@ import world.phantasmal.webui.widgets.Widget class QuestEditor( private val assetLoader: AssetLoader, private val uiStore: UiStore, - private val createThreeRenderer: () -> DisposableThreeRenderer, + private val createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, ) : DisposableContainer(), PwTool { override val toolType = PwToolType.QuestEditor @@ -50,17 +49,13 @@ class QuestEditor( val assemblyEditorController = addDisposable(AssemblyEditorController(assemblyEditorStore)) // Rendering - val renderer = addDisposable(QuestRenderer(createThreeRenderer)) - addDisposables( - QuestEditorMeshManager( - scope, - questEditorStore, - renderer, - areaAssetLoader, - entityAssetLoader - ), - UserInputManager(questEditorStore, renderer) - ) + val renderer = addDisposable(QuestRenderer( + scope, + areaAssetLoader, + entityAssetLoader, + questEditorStore, + createThreeRenderer, + )) // Main Widget return QuestEditorWidget( diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/AreaMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/AreaMeshManager.kt index e57e5532..0c1525d5 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/AreaMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/AreaMeshManager.kt @@ -8,18 +8,19 @@ import world.phantasmal.web.questEditor.models.AreaVariantModel private val logger = KotlinLogging.logger {} class AreaMeshManager( - private val renderer: QuestRenderer, + private val renderContext: QuestRenderContext, private val areaAssetLoader: AreaAssetLoader, ) { suspend fun load(episode: Episode?, areaVariant: AreaVariantModel?) { - renderer.clearCollisionGeometry() + renderContext.clearCollisionGeometry() if (episode == null || areaVariant == null) { return } try { - renderer.collisionGeometry = areaAssetLoader.loadCollisionGeometry(episode, areaVariant) + renderContext.collisionGeometry = + areaAssetLoader.loadCollisionGeometry(episode, areaVariant) } catch (e: Exception) { logger.error(e) { "Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}." diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt index f87b8ed9..599d657f 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/EntityMeshManager.kt @@ -17,7 +17,7 @@ private val logger = KotlinLogging.logger {} class EntityMeshManager( private val scope: CoroutineScope, private val questEditorStore: QuestEditorStore, - private val renderer: QuestRenderer, + private val renderContext: QuestRenderContext, private val entityAssetLoader: EntityAssetLoader, ) : DisposableContainer() { /** @@ -28,7 +28,7 @@ class EntityMeshManager( scope, { (type, model) -> val mesh = entityAssetLoader.loadInstancedMesh(type, model) - renderer.entities.add(mesh) + renderContext.entities.add(mesh) EntityInstancedMesh(mesh, questEditorStore.selectedWave) { entity -> // When an entity's model changes, add it again. At this point it has already // been removed from its previous EntityInstancedMesh. @@ -52,7 +52,7 @@ class EntityMeshManager( */ private val highlightedBox = BoxHelper(color = Color(0.7, 0.7, 0.7)).apply { visible = false - renderer.scene.add(this) + renderContext.scene.add(this) } /** @@ -60,44 +60,26 @@ class EntityMeshManager( */ private val selectedBox = BoxHelper(color = Color(0.9, 0.9, 0.9)).apply { visible = false - renderer.scene.add(this) + renderContext.scene.add(this) } init { observe(questEditorStore.highlightedEntity) { entity -> - if (entity == null) { - unmarkHighlighted() - } else { - val instance = getEntityInstance(entity) - - // Mesh might not be loaded yet. - if (instance == null) { - unmarkHighlighted() - } else { - markHighlighted(instance) - } - } + // getEntityInstance can return null at this point because the entity mesh might not be + // loaded yet. + markHighlighted(entity?.let(::getEntityInstance)) } observe(questEditorStore.selectedEntity) { entity -> - if (entity == null) { - unmarkSelected() - } else { - val instance = getEntityInstance(entity) - - // Mesh might not be loaded yet. - if (instance == null) { - unmarkSelected() - } else { - markSelected(instance) - } - } + // getEntityInstance can return null at this point because the entity mesh might not be + // loaded yet. + markSelected(entity?.let(::getEntityInstance)) } } override fun internalDispose() { removeAll() - renderer.entities.clear() + renderContext.entities.clear() super.internalDispose() } @@ -153,64 +135,46 @@ class EntityMeshManager( } } - private fun markHighlighted(instance: EntityInstance) { + private fun markHighlighted(instance: EntityInstance?) { if (instance == selectedEntityInstance) { highlightedEntityInstance?.follower = null highlightedEntityInstance = null highlightedBox.visible = false - return - } - - if (instance != highlightedEntityInstance) { - highlightedEntityInstance?.follower = null - - highlightedBox.setFromObject(instance.mesh) - instance.follower = highlightedBox - highlightedBox.visible = true - } - - highlightedEntityInstance = instance - } - - private fun unmarkHighlighted() { - highlightedEntityInstance?.let { highlighted -> - if (highlighted != selectedEntityInstance) { - highlighted.follower = null - } - - highlightedEntityInstance = null - highlightedBox.visible = false + } else { + attachBoxHelper( + highlightedBox, + highlightedEntityInstance, + instance + ) + highlightedEntityInstance = instance } } - private fun markSelected(instance: EntityInstance) { + private fun markSelected(instance: EntityInstance?) { if (instance == highlightedEntityInstance) { highlightedBox.visible = false + highlightedEntityInstance = null } - if (instance != selectedEntityInstance) { - selectedEntityInstance?.follower = null - - selectedBox.setFromObject(instance.mesh) - instance.follower = selectedBox - selectedBox.visible = true - } - + attachBoxHelper(selectedBox, selectedEntityInstance, instance) selectedEntityInstance = instance } - private fun unmarkSelected() { - selectedEntityInstance?.let { selected -> - if (selected == highlightedEntityInstance) { - highlightedBox.setFromObject(selected.mesh) - selected.follower = highlightedBox - highlightedBox.visible = true - } else { - selected.follower = null - } + private fun attachBoxHelper( + box: BoxHelper, + oldInstance: EntityInstance?, + newInstance: EntityInstance?, + ) { + box.visible = newInstance != null - selectedEntityInstance = null - selectedBox.visible = false + if (oldInstance == newInstance) return + + oldInstance?.follower = null + + if (newInstance != null) { + box.setFromObject(newInstance.mesh) + newInstance.follower = box + box.visible = true } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt index bed63b20..e48eb938 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestEditorMeshManager.kt @@ -11,11 +11,11 @@ import world.phantasmal.web.questEditor.stores.QuestEditorStore class QuestEditorMeshManager( scope: CoroutineScope, - questEditorStore: QuestEditorStore, - renderer: QuestRenderer, areaAssetLoader: AreaAssetLoader, entityAssetLoader: EntityAssetLoader, -) : QuestMeshManager(scope, questEditorStore, renderer, areaAssetLoader, entityAssetLoader) { + questEditorStore: QuestEditorStore, + renderContext: QuestRenderContext, +) : QuestMeshManager(scope, areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) { init { addDisposables( questEditorStore.currentQuest.map(questEditorStore.currentArea, ::getAreaVariantDetails) diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt index aef74e59..c6dca864 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestMeshManager.kt @@ -20,18 +20,15 @@ import world.phantasmal.webui.DisposableContainer */ abstract class QuestMeshManager protected constructor( private val scope: CoroutineScope, - questEditorStore: QuestEditorStore, - private val renderer: QuestRenderer, areaAssetLoader: AreaAssetLoader, entityAssetLoader: EntityAssetLoader, + questEditorStore: QuestEditorStore, + renderContext: QuestRenderContext, ) : DisposableContainer() { private val areaDisposer = addDisposable(Disposer()) - private val areaMeshManager = AreaMeshManager(renderer, areaAssetLoader) - private val npcMeshManager = addDisposable( - EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader) - ) - private val objectMeshManager = addDisposable( - EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader) + private val areaMeshManager = AreaMeshManager(renderContext, areaAssetLoader) + private val entityMeshManager = addDisposable( + EntityMeshManager(scope, questEditorStore, renderContext, entityAssetLoader) ) private var loadJob: Job? = null @@ -46,10 +43,7 @@ abstract class QuestMeshManager protected constructor( loadJob = scope.launch { // Reset models. areaDisposer.disposeAll() - npcMeshManager.removeAll() - objectMeshManager.removeAll() - - renderer.resetCamera() + entityMeshManager.removeAll() // Load area model. areaMeshManager.load(episode, areaVariant) @@ -64,15 +58,15 @@ abstract class QuestMeshManager protected constructor( private fun npcsChanged(change: ListValChangeEvent) { if (change is ListValChangeEvent.Change) { - change.removed.forEach(npcMeshManager::remove) - change.inserted.forEach(npcMeshManager::add) + change.removed.forEach(entityMeshManager::remove) + change.inserted.forEach(entityMeshManager::add) } } private fun objectsChanged(change: ListValChangeEvent) { if (change is ListValChangeEvent.Change) { - change.removed.forEach(objectMeshManager::remove) - change.inserted.forEach(objectMeshManager::add) + change.removed.forEach(entityMeshManager::remove) + change.inserted.forEach(entityMeshManager::add) } } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderContext.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderContext.kt new file mode 100644 index 00000000..223717d2 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderContext.kt @@ -0,0 +1,34 @@ +package world.phantasmal.web.questEditor.rendering + +import org.w3c.dom.HTMLCanvasElement +import world.phantasmal.web.core.rendering.RenderContext +import world.phantasmal.web.externals.three.Camera +import world.phantasmal.web.externals.three.Group +import world.phantasmal.web.externals.three.Object3D + +class QuestRenderContext( + canvas: HTMLCanvasElement, + camera: Camera, +) : RenderContext(canvas, camera) { + val entities: Object3D = Group().apply { + name = "Entities" + scene.add(this) + } + + var collisionGeometry: Object3D = DEFAULT_COLLISION_GEOMETRY + set(geom) { + scene.remove(field) + field = geom + scene.add(geom) + } + + fun clearCollisionGeometry() { + collisionGeometry = DEFAULT_COLLISION_GEOMETRY + } + + companion object { + private val DEFAULT_COLLISION_GEOMETRY = Group().apply { + name = "Default Collision Geometry" + } + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt index d0bf3961..70f008ca 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/QuestRenderer.kt @@ -1,50 +1,48 @@ package world.phantasmal.web.questEditor.rendering +import kotlinx.coroutines.CoroutineScope +import org.w3c.dom.HTMLCanvasElement import world.phantasmal.web.core.rendering.DisposableThreeRenderer import world.phantasmal.web.core.rendering.Renderer -import world.phantasmal.web.externals.three.Group -import world.phantasmal.web.externals.three.Object3D import world.phantasmal.web.externals.three.PerspectiveCamera +import world.phantasmal.web.questEditor.loading.AreaAssetLoader +import world.phantasmal.web.questEditor.loading.EntityAssetLoader +import world.phantasmal.web.questEditor.rendering.input.QuestInputManager +import world.phantasmal.web.questEditor.stores.QuestEditorStore class QuestRenderer( - createThreeRenderer: () -> DisposableThreeRenderer, -) : Renderer( - createThreeRenderer, - PerspectiveCamera( - fov = 45.0, - aspect = 1.0, - near = 10.0, - far = 5_000.0 - ) -) { - val entities: Object3D = Group().apply { - name = "Entities" - scene.add(this) - } + scope: CoroutineScope, + areaAssetLoader: AreaAssetLoader, + entityAssetLoader: EntityAssetLoader, + questEditorStore: QuestEditorStore, + createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, +) : Renderer() { + override val context = addDisposable(QuestRenderContext( + createCanvas(), + PerspectiveCamera( + fov = 45.0, + aspect = 1.0, + near = 10.0, + far = 5_000.0 + ) + )) - var collisionGeometry: Object3D = DEFAULT_COLLISION_GEOMETRY - set(geom) { - scene.remove(field) - field = geom - scene.add(geom) - } + override val threeRenderer = addDisposable(createThreeRenderer(context.canvas)).renderer - override fun initializeControls() { - super.initializeControls() - camera.position.set(0.0, 800.0, 700.0) - controls.target.set(0.0, 0.0, 0.0) - controls.screenSpacePanning = false - controls.update() - controls.saveState() - } + override val inputManager = addDisposable(QuestInputManager(questEditorStore, context)) - fun clearCollisionGeometry() { - collisionGeometry = DEFAULT_COLLISION_GEOMETRY - } + init { + addDisposables( + QuestEditorMeshManager( + scope, + areaAssetLoader, + entityAssetLoader, + questEditorStore, + context, + ), + ) - companion object { - private val DEFAULT_COLLISION_GEOMETRY = Group().apply { - name = "Default Collision Geometry" - } + observe(questEditorStore.currentQuest) { inputManager.resetCamera() } + observe(questEditorStore.currentArea) { inputManager.resetCamera() } } } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt deleted file mode 100644 index 52c54b03..00000000 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt +++ /dev/null @@ -1,538 +0,0 @@ -package world.phantasmal.web.questEditor.rendering - -import kotlinx.browser.document -import mu.KotlinLogging -import org.w3c.dom.pointerevents.PointerEvent -import world.phantasmal.core.disposable.Disposable -import world.phantasmal.web.core.minus -import world.phantasmal.web.core.plusAssign -import world.phantasmal.web.externals.three.* -import world.phantasmal.web.questEditor.actions.TranslateEntityAction -import world.phantasmal.web.questEditor.models.QuestEntityModel -import world.phantasmal.web.questEditor.models.SectionModel -import world.phantasmal.web.questEditor.stores.QuestEditorStore -import world.phantasmal.webui.DisposableContainer -import world.phantasmal.webui.dom.disposableListener - -private val logger = KotlinLogging.logger {} - -private val ZERO_VECTOR_2 = Vector2(0.0, 0.0) -private val ZERO_VECTOR_3 = Vector3(0.0, 0.0, 0.0) -private val UP_VECTOR = Vector3(0.0, 1.0, 0.0) -private val DOWN_VECTOR = Vector3(0.0, -1.0, 0.0) - -class UserInputManager( - questEditorStore: QuestEditorStore, - private val renderer: QuestRenderer, -) : DisposableContainer() { - private val stateContext = StateContext(questEditorStore, renderer) - private val pointerPosition = Vector2() - private val pointerDevicePosition = Vector2() - private val lastPointerPosition = Vector2() - private var movedSinceLastPointerDown = false - private var state: State - private var onPointerUpListener: Disposable? = null - private var onPointerMoveListener: Disposable? = null - - /** - * Whether entity transformations, deletions, etc. are enabled or not. - * Hover over and selection still work when this is set to false. - */ - var entityManipulationEnabled: Boolean = true - set(enabled) { - field = enabled - state.cancel() - state = IdleState(stateContext, enabled) - } - - init { - state = IdleState(stateContext, entityManipulationEnabled) - - observe(questEditorStore.selectedEntity) { state.cancel() } - - addDisposables( - disposableListener(renderer.canvas, "pointerdown", ::onPointerDown) - ) - - onPointerMoveListener = disposableListener(document, "pointermove", ::onPointerMove) - - renderer.initializeControls() - } - - override fun internalDispose() { - onPointerUpListener?.dispose() - onPointerMoveListener?.dispose() - super.internalDispose() - } - - private fun onPointerDown(e: PointerEvent) { - processPointerEvent(e) - - state = state.processEvent( - PointerDownEvt( - e.buttons.toInt(), - shiftKeyDown = e.shiftKey, - movedSinceLastPointerDown, - pointerDevicePosition, - ) - ) - - onPointerUpListener = disposableListener(document, "pointerup", ::onPointerUp) - - // Stop listening to canvas move events and start listening to document move events. - onPointerMoveListener?.dispose() - onPointerMoveListener = disposableListener(document, "pointermove", ::onPointerMove) - } - - private fun onPointerUp(e: PointerEvent) { - try { - processPointerEvent(e) - - state = state.processEvent( - PointerUpEvt( - e.buttons.toInt(), - shiftKeyDown = e.shiftKey, - movedSinceLastPointerDown, - pointerDevicePosition, - ) - ) - } finally { - onPointerUpListener?.dispose() - onPointerUpListener = null - - // Stop listening to document move events and start listening to canvas move events. - onPointerMoveListener?.dispose() - onPointerMoveListener = - disposableListener(renderer.canvas, "pointermove", ::onPointerMove) - } - } - - private fun onPointerMove(e: PointerEvent) { - processPointerEvent(e) - - state = state.processEvent( - PointerMoveEvt( - e.buttons.toInt(), - shiftKeyDown = e.shiftKey, - movedSinceLastPointerDown, - pointerDevicePosition, - ) - ) - } - - private fun processPointerEvent(e: PointerEvent) { - val rect = renderer.canvas.getBoundingClientRect() - pointerPosition.set(e.clientX - rect.left, e.clientY - rect.top) - pointerDevicePosition.copy(pointerPosition) - renderer.pointerPosToDeviceCoords(pointerDevicePosition) - - when (e.type) { - "pointerdown" -> { - movedSinceLastPointerDown = false - } - "pointermove", "pointerup" -> { - if (!pointerPosition.equals(lastPointerPosition)) { - movedSinceLastPointerDown = true - } - } - } - - lastPointerPosition.copy(pointerPosition) - } -} - -private class StateContext( - private val questEditorStore: QuestEditorStore, - val renderer: QuestRenderer, -) { - val scene = renderer.scene - - fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) { - questEditorStore.setHighlightedEntity(entity) - } - - fun setSelectedEntity(entity: QuestEntityModel<*, *>?) { - questEditorStore.setSelectedEntity(entity) - } - - fun finalizeTranslation( - entity: QuestEntityModel<*, *>, - newSection: SectionModel?, - oldSection: SectionModel?, - newPosition: Vector3, - oldPosition: Vector3, - world: Boolean, - ) { - questEditorStore.executeAction(TranslateEntityAction( - ::setSelectedEntity, - entity, - newSection, - oldSection, - newPosition, - oldPosition, - world, - )) - } - - /** - * @param origin position in normalized device space. - */ - fun pickGround(origin: Vector2, dragAdjust: Vector3 = ZERO_VECTOR_3): Intersection? = - intersectObject(origin, renderer.collisionGeometry, dragAdjust) { intersection -> - // Don't allow entities to be placed on very steep terrain. E.g. walls. - // TODO: make use of the flags field in the collision data. - intersection.face?.normal?.let { n -> n.y > 0.75 } ?: false - } - - inline fun intersectObject( - origin: Vector3, - direction: Vector3, - obj3d: Object3D, - predicate: (Intersection) -> Boolean = { true }, - ): Intersection? { - raycaster.set(origin, direction) - raycasterIntersections.asDynamic().splice(0) - raycaster.intersectObject(obj3d, recursive = true, raycasterIntersections) - return raycasterIntersections.find(predicate) - } - - /** - * The ray's direction is determined by the camera. - * - * @param origin ray origin in normalized device space. - * @param translateOrigin vector by which to translate the ray's origin after construction from - * the camera. - */ - inline fun intersectObject( - origin: Vector2, - obj3d: Object3D, - translateOrigin: Vector3 = ZERO_VECTOR_3, - predicate: (Intersection) -> Boolean = { true }, - ): Intersection? { - raycaster.setFromCamera(origin, renderer.camera) - raycaster.ray.origin += translateOrigin - raycasterIntersections.asDynamic().splice(0) - raycaster.intersectObject(obj3d, recursive = true, raycasterIntersections) - return raycasterIntersections.find(predicate) - } - - fun intersectPlane(origin: Vector2, plane: Plane, intersectionPoint: Vector3): Vector3? { - raycaster.setFromCamera(origin, renderer.camera) - return raycaster.ray.intersectPlane(plane, intersectionPoint) - } - - companion object { - private val raycaster = Raycaster() - private val raycasterIntersections = arrayOf() - } -} - -private sealed class Evt - -private sealed class PointerEvt : Evt() { - abstract val buttons: Int - abstract val shiftKeyDown: Boolean - abstract val movedSinceLastPointerDown: Boolean - - /** - * Pointer position in normalized device space. - */ - abstract val pointerDevicePosition: Vector2 -} - -private class PointerDownEvt( - override val buttons: Int, - override val shiftKeyDown: Boolean, - override val movedSinceLastPointerDown: Boolean, - override val pointerDevicePosition: Vector2, -) : PointerEvt() - -private class PointerUpEvt( - override val buttons: Int, - override val shiftKeyDown: Boolean, - override val movedSinceLastPointerDown: Boolean, - override val pointerDevicePosition: Vector2, -) : PointerEvt() - -private class PointerMoveEvt( - override val buttons: Int, - override val shiftKeyDown: Boolean, - override val movedSinceLastPointerDown: Boolean, - override val pointerDevicePosition: Vector2, -) : PointerEvt() - -private class Pick( - val entity: QuestEntityModel<*, *>, - val mesh: InstancedMesh, - - /** - * Vector that points from the grabbing point (somewhere on the model's surface) to the entity's - * origin. - */ - val grabOffset: Vector3, - - /** - * Vector that points from the grabbing point to the terrain point directly under the entity's - * origin. - */ - val dragAdjust: Vector3, -) - -private abstract class State { - init { - logger.trace { "Transitioning to ${this::class.simpleName}." } - } - - abstract fun processEvent(event: Evt): State - - /** - * The state object should stop doing what it's doing and revert to the idle state as soon as - * possible. - */ - abstract fun cancel() -} - -private class IdleState( - private val ctx: StateContext, - private val entityManipulationEnabled: Boolean, -) : State() { - private var panning = false - private var rotating = false - private var zooming = false - - override fun processEvent(event: Evt): State { - when (event) { - is PointerDownEvt -> { - val pick = pickEntity(event.pointerDevicePosition) - - when (event.buttons) { - 1 -> { - if (pick == null) { - panning = true - } else { - ctx.setSelectedEntity(pick.entity) - - if (entityManipulationEnabled) { - return TranslationState( - ctx, - pick.entity, - pick.dragAdjust, - pick.grabOffset - ) - } - } - } - 2 -> { - if (pick == null) { - rotating = true - } else { - ctx.setSelectedEntity(pick.entity) - - if (entityManipulationEnabled) { - // TODO: Enter RotationState. - } - } - } - 4 -> { - zooming = true - } - } - } - - is PointerUpEvt -> { - if (panning) { - updateCameraTarget() - } - - panning = false - rotating = false - zooming = false - - // If the user clicks on nothing, deselect the currently selected entity. - if (!event.movedSinceLastPointerDown && - pickEntity(event.pointerDevicePosition) == null - ) { - ctx.setSelectedEntity(null) - } - } - - is PointerMoveEvt -> { - if (!panning && !rotating && !zooming) { - // User is hovering. - ctx.setHighlightedEntity(pickEntity(event.pointerDevicePosition)?.entity) - } - } - } - - return this - } - - override fun cancel() { - // Do nothing. - } - - private fun updateCameraTarget() { - // If the user moved the camera, try setting the camera target to a better point. - ctx.pickGround(ZERO_VECTOR_2)?.let { intersection -> - ctx.renderer.controls.target = intersection.point - ctx.renderer.controls.update() - } - } - - /** - * @param pointerPosition pointer coordinates in normalized device space - */ - private fun pickEntity(pointerPosition: Vector2): Pick? { - // Find the nearest entity under the pointer. - val intersection = ctx.intersectObject( - pointerPosition, - ctx.renderer.entities, - ) { it.`object`.visible } - - intersection ?: return null - - val entityInstancedMesh = intersection.`object`.userData - val instanceIndex = intersection.instanceId - - if (instanceIndex == null || entityInstancedMesh !is EntityInstancedMesh) { - return null - } - - val entity = entityInstancedMesh.getInstanceAt(instanceIndex).entity - val entityPosition = entity.worldPosition.value - - // Vector from the point where we grab the entity to its position. - val grabOffset = entityPosition - intersection.point - - // Vector from the point where we grab the entity to the point on the ground right beneath - // its position. The same as grabOffset when an entity is standing on the ground. - val dragAdjust = grabOffset.clone() - - // Find vertical distance to the ground. - ctx.intersectObject( - origin = entityPosition, - direction = DOWN_VECTOR, - ctx.renderer.collisionGeometry, - )?.let { groundIntersection -> - dragAdjust.y -= groundIntersection.distance - } - - return Pick( - entity, - intersection.`object` as InstancedMesh, - grabOffset, - dragAdjust, - ) - } -} - -private class TranslationState( - private val ctx: StateContext, - private val entity: QuestEntityModel<*, *>, - private val dragAdjust: Vector3, - private val grabOffset: Vector3, -) : State() { - private val initialSection: SectionModel? = entity.section.value - private val initialPosition: Vector3 = entity.worldPosition.value - private var cancelled = false - - init { - ctx.renderer.controls.enabled = false - } - - override fun processEvent(event: Evt): State = - when (event) { - is PointerMoveEvt -> { - if (cancelled) { - IdleState(ctx, entityManipulationEnabled = true) - } else { - if (event.movedSinceLastPointerDown) { - translate(event.pointerDevicePosition, vertically = event.shiftKeyDown) - } - - this - } - } - - is PointerUpEvt -> { - ctx.renderer.controls.enabled = true - - if (!cancelled && event.movedSinceLastPointerDown) { - ctx.finalizeTranslation( - entity, - entity.section.value, - initialSection, - entity.worldPosition.value, - initialPosition, - true, - ) - } - - IdleState(ctx, entityManipulationEnabled = true) - } - - else -> { - if (cancelled) { - IdleState(ctx, entityManipulationEnabled = true) - } else this - } - } - - override fun cancel() { - cancelled = true - ctx.renderer.controls.enabled = true - - initialSection?.let { - entity.setSection(initialSection) - } - - entity.setWorldPosition(initialPosition) - } - - /** - * @param pointerPosition pointer position in normalized device space - */ - private fun translate(pointerPosition: Vector2, vertically: Boolean) { - if (vertically) { - // TODO: Vertical translation. - } else { - translateEntityHorizontally(pointerPosition) - } - } - - /** - * If the drag-adjusted pointer is over the ground, translate an entity horizontally across the - * ground. Otherwise translate the entity over the horizontal plane that intersects its origin. - */ - private fun translateEntityHorizontally(pointerPosition: Vector2) { - val pick = ctx.pickGround(pointerPosition, dragAdjust) - - if (pick == null) { - // If the pointer is not over the ground, we translate the entity across the horizontal - // plane in which the entity's origin lies. - plane.set(UP_VECTOR, -entity.worldPosition.value.y + grabOffset.y) - - ctx.intersectPlane(pointerPosition, plane, tmpVec)?.let { pointerPosOnPlane -> - entity.setWorldPosition(Vector3( - pointerPosOnPlane.x + grabOffset.x, - entity.worldPosition.value.y, - pointerPosOnPlane.z + grabOffset.z, - )) - } - } else { - // TODO: Set entity section. - entity.setWorldPosition( - Vector3( - pick.point.x, - pick.point.y + grabOffset.y - dragAdjust.y, - pick.point.z, - ) - ) - } - } - - companion object { - private val plane = Plane() - private val tmpVec = Vector3() - } -} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt new file mode 100644 index 00000000..692f821d --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/Evt.kt @@ -0,0 +1,37 @@ +package world.phantasmal.web.questEditor.rendering.input + +import world.phantasmal.web.externals.three.Vector2 + +sealed class Evt + +sealed class PointerEvt : Evt() { + abstract val buttons: Int + abstract val shiftKeyDown: Boolean + abstract val movedSinceLastPointerDown: Boolean + + /** + * Pointer position in normalized device space. + */ + abstract val pointerDevicePosition: Vector2 +} + +class PointerDownEvt( + override val buttons: Int, + override val shiftKeyDown: Boolean, + override val movedSinceLastPointerDown: Boolean, + override val pointerDevicePosition: Vector2, +) : PointerEvt() + +class PointerUpEvt( + override val buttons: Int, + override val shiftKeyDown: Boolean, + override val movedSinceLastPointerDown: Boolean, + override val pointerDevicePosition: Vector2, +) : PointerEvt() + +class PointerMoveEvt( + override val buttons: Int, + override val shiftKeyDown: Boolean, + override val movedSinceLastPointerDown: Boolean, + override val pointerDevicePosition: Vector2, +) : PointerEvt() diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt new file mode 100644 index 00000000..ea07048a --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/QuestInputManager.kt @@ -0,0 +1,164 @@ +package world.phantasmal.web.questEditor.rendering.input + +import kotlinx.browser.document +import org.w3c.dom.pointerevents.PointerEvent +import world.phantasmal.core.disposable.Disposable +import world.phantasmal.web.core.rendering.InputManager +import world.phantasmal.web.core.rendering.OrbitalCameraInputManager +import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.web.externals.three.Vector3 +import world.phantasmal.web.questEditor.rendering.QuestRenderContext +import world.phantasmal.web.questEditor.rendering.input.state.IdleState +import world.phantasmal.web.questEditor.rendering.input.state.State +import world.phantasmal.web.questEditor.rendering.input.state.StateContext +import world.phantasmal.web.questEditor.stores.QuestEditorStore +import world.phantasmal.webui.DisposableContainer +import world.phantasmal.webui.dom.disposableListener + +class QuestInputManager( + questEditorStore: QuestEditorStore, + private val renderContext: QuestRenderContext, +) : DisposableContainer(), InputManager { + private val stateContext: StateContext + private val pointerPosition = Vector2() + private val pointerDevicePosition = Vector2() + private val lastPointerPosition = Vector2() + private var movedSinceLastPointerDown = false + private var state: State + private var onPointerUpListener: Disposable? = null + private var onPointerMoveListener: Disposable? = null + + private val cameraInputManager: OrbitalCameraInputManager + + /** + * Whether entity transformations, deletions, etc. are enabled or not. + * Hover over and selection still work when this is set to false. + */ + var entityManipulationEnabled: Boolean = true + set(enabled) { + field = enabled + returnToIdleState() + } + + init { + addDisposables( + disposableListener(renderContext.canvas, "pointerdown", ::onPointerDown) + ) + + onPointerMoveListener = disposableListener(document, "pointermove", ::onPointerMove) + + // Ensure OrbitalCameraControls attaches its listeners after ours. + cameraInputManager = OrbitalCameraInputManager( + renderContext.canvas, + renderContext.camera, + position = Vector3(0.0, 800.0, 700.0), + screenSpacePanning = false, + ) + + stateContext = StateContext(questEditorStore, renderContext, cameraInputManager) + state = IdleState(stateContext, entityManipulationEnabled) + observe(questEditorStore.selectedEntity) { returnToIdleState() } + } + + override fun internalDispose() { + cameraInputManager.dispose() + onPointerUpListener?.dispose() + onPointerMoveListener?.dispose() + super.internalDispose() + } + + override fun setSize(width: Double, height: Double) { + cameraInputManager.setSize(width, height) + } + + override fun resetCamera() { + cameraInputManager.resetCamera() + } + + override fun beforeRender() { + state.beforeRender() + cameraInputManager.beforeRender() + } + + private fun onPointerDown(e: PointerEvent) { + processPointerEvent(e) + + state = state.processEvent( + PointerDownEvt( + e.buttons.toInt(), + shiftKeyDown = e.shiftKey, + movedSinceLastPointerDown, + pointerDevicePosition, + ) + ) + + onPointerUpListener = disposableListener(document, "pointerup", ::onPointerUp) + + // Stop listening to canvas move events and start listening to document move events. + onPointerMoveListener?.dispose() + onPointerMoveListener = disposableListener(document, "pointermove", ::onPointerMove) + } + + private fun onPointerUp(e: PointerEvent) { + try { + processPointerEvent(e) + + state = state.processEvent( + PointerUpEvt( + e.buttons.toInt(), + shiftKeyDown = e.shiftKey, + movedSinceLastPointerDown, + pointerDevicePosition, + ) + ) + } finally { + onPointerUpListener?.dispose() + onPointerUpListener = null + + // Stop listening to document move events and start listening to canvas move events. + onPointerMoveListener?.dispose() + onPointerMoveListener = + disposableListener(renderContext.canvas, "pointermove", ::onPointerMove) + } + } + + private fun onPointerMove(e: PointerEvent) { + processPointerEvent(e) + + state = state.processEvent( + PointerMoveEvt( + e.buttons.toInt(), + shiftKeyDown = e.shiftKey, + movedSinceLastPointerDown, + pointerDevicePosition, + ) + ) + } + + private fun processPointerEvent(e: PointerEvent) { + val rect = renderContext.canvas.getBoundingClientRect() + pointerPosition.set(e.clientX - rect.left, e.clientY - rect.top) + pointerDevicePosition.copy(pointerPosition) + renderContext.pointerPosToDeviceCoords(pointerDevicePosition) + + when (e.type) { + "pointerdown" -> { + movedSinceLastPointerDown = false + } + "pointermove", "pointerup" -> { + if (!pointerPosition.equals(lastPointerPosition)) { + movedSinceLastPointerDown = true + } + } + } + + lastPointerPosition.copy(pointerPosition) + } + + private fun returnToIdleState() { + if (state !is IdleState) { + state.cancel() + state = IdleState(stateContext, entityManipulationEnabled) + } + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt new file mode 100644 index 00000000..127b3251 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/IdleState.kt @@ -0,0 +1,174 @@ +package world.phantasmal.web.questEditor.rendering.input.state + +import world.phantasmal.web.core.minus +import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.web.externals.three.Vector3 +import world.phantasmal.web.questEditor.models.QuestEntityModel +import world.phantasmal.web.questEditor.rendering.EntityInstancedMesh +import world.phantasmal.web.questEditor.rendering.input.Evt +import world.phantasmal.web.questEditor.rendering.input.PointerDownEvt +import world.phantasmal.web.questEditor.rendering.input.PointerMoveEvt +import world.phantasmal.web.questEditor.rendering.input.PointerUpEvt + +class IdleState( + private val ctx: StateContext, + private val entityManipulationEnabled: Boolean, +) : State() { + private var panning = false + private var rotating = false + private var zooming = false + private val pointerDevicePosition = Vector2() + private var shouldCheckHighlight = false + + override fun processEvent(event: Evt): State { + when (event) { + is PointerDownEvt -> { + val pick = pickEntity(event.pointerDevicePosition) + + when (event.buttons) { + 1 -> { + if (pick == null) { + panning = true + } else { + ctx.setSelectedEntity(pick.entity) + + if (entityManipulationEnabled) { + return TranslationState( + ctx, + pick.entity, + pick.dragAdjust, + pick.grabOffset, + ) + } + } + } + 2 -> { + if (pick == null) { + rotating = true + } else { + ctx.setSelectedEntity(pick.entity) + + if (entityManipulationEnabled) { + return RotationState( + ctx, + pick.entity, + pick.grabOffset, + ) + } + } + } + 4 -> { + zooming = true + } + } + } + + is PointerUpEvt -> { + if (panning) { + updateCameraTarget() + } + + panning = false + rotating = false + zooming = false + + // If the user clicks on nothing, deselect the currently selected entity. + if (!event.movedSinceLastPointerDown && + pickEntity(event.pointerDevicePosition) == null + ) { + ctx.setSelectedEntity(null) + } + } + + is PointerMoveEvt -> { + if (!panning && !rotating && !zooming) { + // User is hovering. + pointerDevicePosition.copy(event.pointerDevicePosition) + shouldCheckHighlight = true + } + } + } + + return this + } + + override fun beforeRender() { + if (shouldCheckHighlight) { + ctx.setHighlightedEntity(pickEntity(pointerDevicePosition)?.entity) + shouldCheckHighlight = false + } + } + + override fun cancel() { + // Do nothing. + } + + private fun updateCameraTarget() { + // If the user moved the camera, try setting the camera target to a better point. + ctx.pickGround(ZERO_VECTOR_2)?.let { intersection -> + ctx.cameraInputManager.setTarget(intersection.point) + } + } + + /** + * @param pointerPosition pointer coordinates in normalized device space + */ + private fun pickEntity(pointerPosition: Vector2): Pick? { + // Find the nearest entity under the pointer. + val intersection = ctx.intersectObject( + pointerPosition, + ctx.renderContext.entities, + ) { it.`object`.visible } + + intersection ?: return null + + val entityInstancedMesh = intersection.`object`.userData + val instanceIndex = intersection.instanceId + + if (instanceIndex == null || entityInstancedMesh !is EntityInstancedMesh) { + return null + } + + val entity = entityInstancedMesh.getInstanceAt(instanceIndex).entity + val entityPosition = entity.worldPosition.value + + // Vector from the point where we grab the entity to its position. + val grabOffset = entityPosition - intersection.point + + // Vector from the point where we grab the entity to the point on the ground right beneath + // its position. The same as grabOffset when an entity is standing on the ground. + val dragAdjust = grabOffset.clone() + + // Find vertical distance to the ground. + ctx.intersectObject( + origin = entityPosition, + direction = DOWN_VECTOR, + ctx.renderContext.collisionGeometry, + )?.let { groundIntersection -> + dragAdjust.y -= groundIntersection.distance + } + + return Pick(entity, grabOffset, dragAdjust) + } + + private class Pick( + val entity: QuestEntityModel<*, *>, + + /** + * Vector that points from the grabbing point (somewhere on the model's surface) to the entity's + * origin. + */ + val grabOffset: Vector3, + + /** + * Vector that points from the grabbing point to the terrain point directly under the entity's + * origin. + */ + val dragAdjust: Vector3, + ) + + companion object { + private val ZERO_VECTOR_2 = Vector2(0.0, 0.0) + private val DOWN_VECTOR = Vector3(0.0, -1.0, 0.0) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/RotationState.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/RotationState.kt new file mode 100644 index 00000000..b0db60cb --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/RotationState.kt @@ -0,0 +1,70 @@ +package world.phantasmal.web.questEditor.rendering.input.state + +import world.phantasmal.web.core.minus +import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.web.externals.three.Vector3 +import world.phantasmal.web.questEditor.models.QuestEntityModel +import world.phantasmal.web.questEditor.rendering.input.Evt +import world.phantasmal.web.questEditor.rendering.input.PointerMoveEvt +import world.phantasmal.web.questEditor.rendering.input.PointerUpEvt + +class RotationState( + private val ctx: StateContext, + private val entity: QuestEntityModel<*, *>, + grabOffset: Vector3, +) : State() { + private val initialRotation = entity.worldRotation.value + private val grabPoint = entity.worldPosition.value - grabOffset + private val pointerDevicePosition = Vector2() + private var shouldRotate = false + + init { + ctx.cameraInputManager.enabled = false + } + + override fun processEvent(event: Evt): State = + when (event) { + is PointerMoveEvt -> { + if (event.movedSinceLastPointerDown) { + pointerDevicePosition.copy(event.pointerDevicePosition) + shouldRotate = true + } + + this + } + + is PointerUpEvt -> { + ctx.cameraInputManager.enabled = true + + if (event.movedSinceLastPointerDown) { + ctx.finalizeEntityRotation( + entity, + entity.worldRotation.value, + initialRotation, + ) + } + + IdleState(ctx, entityManipulationEnabled = true) + } + + else -> this + } + + override fun beforeRender() { + if (shouldRotate) { + ctx.rotateEntity( + entity, + initialRotation, + grabPoint, + pointerDevicePosition, + ) + shouldRotate = false + } + } + + override fun cancel() { + ctx.cameraInputManager.enabled = true + + entity.setWorldRotation(initialRotation) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/State.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/State.kt new file mode 100644 index 00000000..9e3e1ba7 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/State.kt @@ -0,0 +1,22 @@ +package world.phantasmal.web.questEditor.rendering.input.state + +import mu.KotlinLogging +import world.phantasmal.web.questEditor.rendering.input.Evt + +private val logger = KotlinLogging.logger {} + +abstract class State { + init { + logger.trace { "Transitioning to ${this::class.simpleName}." } + } + + abstract fun processEvent(event: Evt): State + + abstract fun beforeRender() + + /** + * The state object should stop doing what it's doing and revert to the idle state as soon as + * possible. + */ + abstract fun cancel() +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt new file mode 100644 index 00000000..f03eb0c3 --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/StateContext.kt @@ -0,0 +1,253 @@ +package world.phantasmal.web.questEditor.rendering.input.state + +import world.phantasmal.web.core.minusAssign +import world.phantasmal.web.core.plusAssign +import world.phantasmal.web.core.rendering.OrbitalCameraInputManager +import world.phantasmal.web.core.toQuaternion +import world.phantasmal.web.externals.three.* +import world.phantasmal.web.questEditor.actions.RotateEntityAction +import world.phantasmal.web.questEditor.actions.TranslateEntityAction +import world.phantasmal.web.questEditor.models.QuestEntityModel +import world.phantasmal.web.questEditor.models.SectionModel +import world.phantasmal.web.questEditor.rendering.QuestRenderContext +import world.phantasmal.web.questEditor.stores.QuestEditorStore +import kotlin.math.PI +import kotlin.math.atan2 + +class StateContext( + private val questEditorStore: QuestEditorStore, + val renderContext: QuestRenderContext, + val cameraInputManager: OrbitalCameraInputManager, +) { + fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) { + questEditorStore.setHighlightedEntity(entity) + } + + fun setSelectedEntity(entity: QuestEntityModel<*, *>?) { + questEditorStore.setSelectedEntity(entity) + } + + /** + * @param pointerPosition pointer position in normalized device space + */ + fun translateEntity( + entity: QuestEntityModel<*, *>, + dragAdjust: Vector3, + grabOffset: Vector3, + pointerPosition: Vector2, + vertically: Boolean, + ) { + if (vertically) { + translateEntityVertically(entity, dragAdjust, grabOffset, pointerPosition) + } else { + translateEntityHorizontally(entity, dragAdjust, grabOffset, pointerPosition) + } + } + + /** + * If the drag-adjusted pointer is over the ground, translate an entity horizontally across the + * ground. Otherwise translate the entity over the horizontal plane that intersects its origin. + */ + private fun translateEntityHorizontally( + entity: QuestEntityModel<*, *>, + dragAdjust: Vector3, + grabOffset: Vector3, + pointerPosition: Vector2, + ) { + val pick = pickGround(pointerPosition, dragAdjust) + + if (pick == null) { + // If the pointer is not over the ground, we translate the entity across the horizontal + // plane in which the entity's origin lies. + plane.set(UP_VECTOR, -entity.worldPosition.value.y + grabOffset.y) + + intersectPlane(pointerPosition, plane, tmpVec0)?.let { pointerPosOnPlane -> + entity.setWorldPosition(Vector3( + pointerPosOnPlane.x + grabOffset.x, + entity.worldPosition.value.y, + pointerPosOnPlane.z + grabOffset.z, + )) + } + } else { + // TODO: Set entity section. + entity.setWorldPosition( + Vector3( + pick.point.x, + pick.point.y + grabOffset.y - dragAdjust.y, + pick.point.z, + ) + ) + } + } + + private fun translateEntityVertically( + entity: QuestEntityModel<*, *>, + dragAdjust: Vector3, + grabOffset: Vector3, + pointerPosition: Vector2, + ) { + // Intersect with a plane that's oriented towards the camera and that's coplanar with the + // point where the entity was grabbed. + val planeNormal = renderContext.camera.getWorldDirection(tmpVec0) + planeNormal.negate() + planeNormal.y = 0.0 + planeNormal.normalize() + + val entityPos = entity.worldPosition.value + + val grabPoint = tmpVec1.copy(entityPos) + grabPoint -= grabOffset + plane.setFromNormalAndCoplanarPoint(planeNormal, grabPoint) + + intersectPlane(pointerPosition, plane, tmpVec2)?.let { pointerPosOnPlane -> + val y = pointerPosOnPlane.y + grabOffset.y + val yDelta = y - entityPos.y + dragAdjust.y -= yDelta + entity.setWorldPosition(Vector3( + entityPos.x, + y, + entityPos.z, + )) + } + } + + fun finalizeEntityTranslation( + entity: QuestEntityModel<*, *>, + newSection: SectionModel?, + oldSection: SectionModel?, + newPosition: Vector3, + oldPosition: Vector3, + ) { + questEditorStore.executeAction(TranslateEntityAction( + ::setSelectedEntity, + entity, + newSection, + oldSection, + newPosition, + oldPosition, + world = true, + )) + } + + /** + * @param pointerPosition pointer position in normalized device space + */ + fun rotateEntity( + entity: QuestEntityModel<*, *>, + initialRotation: Euler, + grabPoint: Vector3, + pointerPosition: Vector2, + ) { + // Intersect with a plane that's oriented along the entity's y-axis and that's coplanar with + // the point where the entity was grabbed. + val planeNormal = tmpVec0.copy(UP_VECTOR) + planeNormal.applyEuler(entity.worldRotation.value) + + plane.setFromNormalAndCoplanarPoint(planeNormal, grabPoint) + + intersectPlane(pointerPosition, plane, tmpVec1)?.let { pointerPosOnPlane -> + val yIntersect = plane.projectPoint(entity.worldPosition.value, tmpVec2) + + // Calculate vector from the entity's y-axis to the original grab point. + val axisToGrab = tmpVec3.subVectors(yIntersect, grabPoint) + + // Calculate vector from the entity's y-axis to the new pointer position. + val axisToPointer = tmpVec4.subVectors(yIntersect, pointerPosOnPlane) + + // Calculate the angle between the two vectors and rotate the entity around its y-axis + // by that angle. + val cos = axisToGrab.dot(axisToPointer) + val sin = planeNormal.dot(axisToGrab.cross(axisToPointer)) + val angle = atan2(sin, cos) + + entity.setWorldRotation( + Euler( + initialRotation.x, + (initialRotation.y + angle) % PI2, + initialRotation.z, + "ZXY", + ), + ) + } + } + + fun finalizeEntityRotation( + entity: QuestEntityModel<*, *>, + newRotation: Euler, + oldRotation: Euler, + ) { + questEditorStore.executeAction(RotateEntityAction( + ::setSelectedEntity, + entity, + newRotation, + oldRotation, + world = true, + )) + } + + /** + * @param origin position in normalized device space. + */ + fun pickGround(origin: Vector2, dragAdjust: Vector3 = ZERO_VECTOR_3): Intersection? = + intersectObject(origin, renderContext.collisionGeometry, dragAdjust) { intersection -> + // Don't allow entities to be placed on very steep terrain. E.g. walls. + // TODO: make use of the flags field in the collision data. + intersection.face?.normal?.let { n -> n.y > 0.75 } ?: false + } + + inline fun intersectObject( + origin: Vector3, + direction: Vector3, + obj3d: Object3D, + predicate: (Intersection) -> Boolean = { true }, + ): Intersection? { + raycaster.set(origin, direction) + raycasterIntersections.asDynamic().splice(0) + raycaster.intersectObject(obj3d, recursive = true, raycasterIntersections) + return raycasterIntersections.find(predicate) + } + + /** + * The ray's direction is determined by the camera. + * + * @param origin ray origin in normalized device space. + * @param translateOrigin vector by which to translate the ray's origin after construction from + * the camera. + */ + inline fun intersectObject( + origin: Vector2, + obj3d: Object3D, + translateOrigin: Vector3 = ZERO_VECTOR_3, + predicate: (Intersection) -> Boolean = { true }, + ): Intersection? { + raycaster.setFromCamera(origin, renderContext.camera) + raycaster.ray.origin += translateOrigin + raycasterIntersections.asDynamic().splice(0) + raycaster.intersectObject(obj3d, recursive = true, raycasterIntersections) + return raycasterIntersections.find(predicate) + } + + private fun intersectPlane( + origin: Vector2, + plane: Plane, + intersectionPoint: Vector3, + ): Vector3? { + raycaster.setFromCamera(origin, renderContext.camera) + return raycaster.ray.intersectPlane(plane, intersectionPoint) + } + + companion object { + private const val PI2: Double = 2 * PI + private val UP_VECTOR = Vector3(0.0, 1.0, 0.0) + val ZERO_VECTOR_3 = Vector3(0.0, 0.0, 0.0) + + private val plane = Plane() + private val tmpVec0 = Vector3() + private val tmpVec1 = Vector3() + private val tmpVec2 = Vector3() + private val tmpVec3 = Vector3() + private val tmpVec4 = Vector3() + val raycaster = Raycaster() + val raycasterIntersections = arrayOf() + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt new file mode 100644 index 00000000..a97fe46a --- /dev/null +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/input/state/TranslationState.kt @@ -0,0 +1,80 @@ +package world.phantasmal.web.questEditor.rendering.input.state + +import world.phantasmal.web.externals.three.Vector2 +import world.phantasmal.web.externals.three.Vector3 +import world.phantasmal.web.questEditor.models.QuestEntityModel +import world.phantasmal.web.questEditor.models.SectionModel +import world.phantasmal.web.questEditor.rendering.input.Evt +import world.phantasmal.web.questEditor.rendering.input.PointerMoveEvt +import world.phantasmal.web.questEditor.rendering.input.PointerUpEvt + +class TranslationState( + private val ctx: StateContext, + private val entity: QuestEntityModel<*, *>, + private val dragAdjust: Vector3, + private val grabOffset: Vector3, +) : State() { + private val initialSection: SectionModel? = entity.section.value + private val initialPosition: Vector3 = entity.worldPosition.value + private val pointerDevicePosition = Vector2() + private var shouldTranslate = false + private var shouldTranslateVertically = false + + init { + ctx.cameraInputManager.enabled = false + } + + override fun processEvent(event: Evt): State = + when (event) { + is PointerMoveEvt -> { + if (event.movedSinceLastPointerDown) { + pointerDevicePosition.copy(event.pointerDevicePosition) + shouldTranslate = true + shouldTranslateVertically = event.shiftKeyDown + } + + this + } + + is PointerUpEvt -> { + ctx.cameraInputManager.enabled = true + + if (event.movedSinceLastPointerDown) { + ctx.finalizeEntityTranslation( + entity, + entity.section.value, + initialSection, + entity.worldPosition.value, + initialPosition, + ) + } + + IdleState(ctx, entityManipulationEnabled = true) + } + + else -> this + } + + override fun beforeRender() { + if (shouldTranslate) { + ctx.translateEntity( + entity, + dragAdjust, + grabOffset, + pointerDevicePosition, + shouldTranslateVertically, + ) + shouldTranslate = false + } + } + + override fun cancel() { + ctx.cameraInputManager.enabled = true + + initialSection?.let { + entity.setSection(initialSection) + } + + entity.setWorldPosition(initialPosition) + } +} diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt index fdd6d946..752a0f83 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/Viewer.kt @@ -1,6 +1,7 @@ package world.phantasmal.web.viewer import kotlinx.coroutines.CoroutineScope +import org.w3c.dom.HTMLCanvasElement import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.rendering.DisposableThreeRenderer @@ -16,7 +17,7 @@ import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.widgets.Widget class Viewer( - private val createThreeRenderer: () -> DisposableThreeRenderer, + private val createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, ) : DisposableContainer(), PwTool { override val toolType = PwToolType.Viewer diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt index 0269ddbd..ec76641b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/MeshRenderer.kt @@ -1,41 +1,44 @@ package world.phantasmal.web.viewer.rendering -import world.phantasmal.web.core.rendering.DisposableThreeRenderer -import world.phantasmal.web.core.rendering.Renderer +import org.w3c.dom.HTMLCanvasElement +import world.phantasmal.web.core.rendering.* import world.phantasmal.web.core.rendering.conversion.ninjaObjectToMesh -import world.phantasmal.web.core.rendering.disposeObject3DResources import world.phantasmal.web.externals.three.BufferGeometry import world.phantasmal.web.externals.three.Mesh import world.phantasmal.web.externals.three.PerspectiveCamera +import world.phantasmal.web.externals.three.Vector3 import world.phantasmal.web.viewer.store.ViewerStore class MeshRenderer( - private val store: ViewerStore, - createThreeRenderer: () -> DisposableThreeRenderer, -) : Renderer( - createThreeRenderer, - PerspectiveCamera( - fov = 45.0, - aspect = 1.0, - near = 1.0, - far = 1_000.0, - ) -) { + private val viewerStore: ViewerStore, + createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, +) : Renderer() { + override val context = addDisposable(RenderContext( + createCanvas(), + PerspectiveCamera( + fov = 45.0, + aspect = 1.0, + near = 10.0, + far = 5_000.0 + ) + )) + + override val threeRenderer = addDisposable(createThreeRenderer(context.canvas)).renderer + + override val inputManager = addDisposable(OrbitalCameraInputManager( + context.canvas, + context.camera, + Vector3(0.0, 25.0, 100.0), + screenSpacePanning = true + )) + private var mesh: Mesh? = null init { - initializeControls() - camera.position.set(0.0, 25.0, 100.0) - controls.target.set(0.0, 0.0, 0.0) - controls.zoomSpeed = 2.0 - controls.screenSpacePanning = true - controls.update() - controls.saveState() - - observe(store.currentNinjaObject) { + observe(viewerStore.currentNinjaObject) { ninjaObjectOrXvmChanged(reset = true) } - observe(store.currentTextures) { + observe(viewerStore.currentTextures) { ninjaObjectOrXvmChanged(reset = false) } } @@ -43,15 +46,15 @@ class MeshRenderer( private fun ninjaObjectOrXvmChanged(reset: Boolean) { mesh?.let { mesh -> disposeObject3DResources(mesh) - scene.remove(mesh) + context.scene.remove(mesh) } if (reset) { - resetCamera() + inputManager.resetCamera() } - val ninjaObject = store.currentNinjaObject.value - val textures = store.currentTextures.value + val ninjaObject = viewerStore.currentNinjaObject.value + val textures = viewerStore.currentTextures.value if (ninjaObject != null) { val mesh = ninjaObjectToMesh(ninjaObject, textures, boundingVolumes = true) @@ -60,7 +63,7 @@ class MeshRenderer( val bb = (mesh.geometry as BufferGeometry).boundingBox!! val height = bb.max.y - bb.min.y mesh.translateY(-height / 2 - bb.min.y) - scene.add(mesh) + context.scene.add(mesh) this.mesh = mesh } diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt index fb387da6..16b70ee2 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/rendering/TextureRenderer.kt @@ -1,9 +1,9 @@ package world.phantasmal.web.viewer.rendering +import org.w3c.dom.HTMLCanvasElement import world.phantasmal.lib.fileFormats.ninja.XvrTexture -import world.phantasmal.web.core.rendering.DisposableThreeRenderer +import world.phantasmal.web.core.rendering.* import world.phantasmal.web.core.rendering.Renderer -import world.phantasmal.web.core.rendering.disposeObject3DResources import world.phantasmal.web.externals.three.* import world.phantasmal.web.viewer.store.ViewerStore import world.phantasmal.webui.obj @@ -13,36 +13,42 @@ import kotlin.math.sqrt class TextureRenderer( store: ViewerStore, - createThreeRenderer: () -> DisposableThreeRenderer, -) : Renderer( - createThreeRenderer, - OrthographicCamera( - left = -400.0, - right = 400.0, - top = 300.0, - bottom = -300.0, - near = 1.0, - far = 10.0, - ) -) { + createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer, +) : Renderer() { private var meshes = listOf() - init { - initializeControls() - camera.position.set(0.0, 0.0, 5.0) - controls.update() - controls.saveState() + override val context = addDisposable(RenderContext( + createCanvas(), + OrthographicCamera( + left = -400.0, + right = 400.0, + top = 300.0, + bottom = -300.0, + near = 1.0, + far = 10.0, + ) + )) + override val threeRenderer = addDisposable(createThreeRenderer(context.canvas)).renderer + + override val inputManager = addDisposable(OrbitalCameraInputManager( + context.canvas, + context.camera, + Vector3(0.0, 0.0, 5.0), + screenSpacePanning = true + )) + + init { observe(store.currentTextures, ::texturesChanged) } private fun texturesChanged(textures: List) { meshes.forEach { mesh -> disposeObject3DResources(mesh) - scene.remove(mesh) + context.scene.remove(mesh) } - resetCamera() + inputManager.resetCamera() // Lay textures out in a square grid of "cells". var cellWidth = -1 @@ -71,7 +77,7 @@ class TextureRenderer( transparent = true }) ) - scene.add(quad) + context.scene.add(quad) x += cellWidth