Improved camera handling.

This commit is contained in:
Daan Vanden Bosch 2020-11-09 21:39:15 +01:00
parent 132cdccd0a
commit 990a8c144f
13 changed files with 180 additions and 61 deletions

View File

@ -9,11 +9,11 @@ private val logger = KotlinLogging.logger {}
abstract class Renderer(
val canvas: HTMLCanvasElement,
protected val engine: Engine,
val engine: Engine,
) : DisposableContainer() {
private val light: HemisphericLight
protected abstract val camera: Camera
abstract val camera: Camera
val scene = Scene(engine)
@ -44,7 +44,7 @@ abstract class Renderer(
engine.stopRenderLoop()
}
private fun render() {
protected open fun render() {
val lightDirection = Vector3(-1.0, 1.0, 1.0)
lightDirection.rotateByQuaternionToRef(camera.absoluteRotation, lightDirection)
light.direction = lightDirection

View File

@ -50,6 +50,7 @@ external class Vector3(x: Double, y: Double, z: Double) {
companion object {
fun One(): Vector3
fun Up(): Vector3
fun Down(): Vector3
fun Zero(): Vector3
fun Dot(left: Vector3, right: Vector3): Double
fun TransformCoordinates(vector: Vector3, transformation: Matrix): Vector3
@ -145,6 +146,8 @@ open external class ThinEngine {
* be removed.
*/
fun stopRenderLoop(renderFunction: () -> Unit = definedExternally)
fun getRenderWidth(useScreen: Boolean = definedExternally): Double
fun getRenderHeight(useScreen: Boolean = definedExternally): Double
fun dispose()
}
@ -154,7 +157,7 @@ external class Engine(
antialias: Boolean = definedExternally,
) : ThinEngine
external class Ray
external class Ray(origin: Vector3, direction: Vector3, length: Double = definedExternally)
external class PickingInfo {
val bu: Double
@ -166,6 +169,13 @@ external class PickingInfo {
val pickedMesh: AbstractMesh?
val pickedPoint: Vector3?
val ray: Ray?
fun getNormal(
useWorldCoordinates: Boolean = definedExternally,
useVerticesNormals: Boolean = definedExternally,
): Vector3?
fun getTextureCoordinates(): Vector2?
}
external class Scene(engine: Engine) {
@ -190,6 +200,32 @@ external class Scene(engine: Engine) {
trianglePredicate: (p0: Vector3, p1: Vector3, p2: Vector3, ray: Ray) -> Boolean = definedExternally,
): PickingInfo?
fun pickWithRay(
ray: Ray,
predicate: (AbstractMesh) -> Boolean = definedExternally,
fastCheck: Boolean = definedExternally,
trianglePredicate: (p0: Vector3, p1: Vector3, p2: Vector3, ray: Ray) -> Boolean = definedExternally,
): PickingInfo?
/**
* @param x X position on screen
* @param y Y position on screen
* @param predicate Predicate function used to determine eligible meshes. Can be set to null. In this case, a mesh must be enabled, visible and with isPickable set to true
*/
fun multiPick(
x: Double,
y: Double,
predicate: (AbstractMesh) -> Boolean = definedExternally,
camera: Camera = definedExternally,
trianglePredicate: (p0: Vector3, p1: Vector3, p2: Vector3, ray: Ray) -> Boolean = definedExternally,
): Array<PickingInfo>?
fun multiPickWithRay(
ray: Ray,
predicate: (AbstractMesh) -> Boolean = definedExternally,
trianglePredicate: (p0: Vector3, p1: Vector3, p2: Vector3, ray: Ray) -> Boolean = definedExternally,
): Array<PickingInfo>?
fun dispose()
}
@ -214,15 +250,21 @@ open external class Node {
}
open external class Camera : Node {
var minZ: Double
var maxZ: Double
val absoluteRotation: Quaternion
val onProjectionMatrixChangedObservable: Observable<Camera>
val onViewMatrixChangedObservable: Observable<Camera>
val onAfterCheckInputsObservable: Observable<Camera>
fun attachControl(noPreventDefault: Boolean = definedExternally)
fun storeState(): Camera
fun restoreState(): Boolean
}
open external class TargetCamera : Camera
open external class TargetCamera : Camera {
var target: Vector3
}
/**
* @param setActiveOnSceneIfNoneActive default true
@ -236,6 +278,9 @@ external class ArcRotateCamera(
scene: Scene,
setActiveOnSceneIfNoneActive: Boolean = definedExternally,
) : TargetCamera {
var alpha: Double
var beta: Double
var radius: Double
var inertia: Double
var angularSensibilityX: Double
var angularSensibilityY: Double
@ -244,6 +289,7 @@ external class ArcRotateCamera(
var panningAxis: Vector3
var pinchDeltaPercentage: Double
var wheelDeltaPercentage: Double
var lowerBetaLimit: Double
fun attachControl(
element: HTMLCanvasElement,

View File

@ -13,7 +13,7 @@ import world.phantasmal.web.questEditor.controllers.QuestInfoController
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.EntityManipulator
import world.phantasmal.web.questEditor.rendering.UserInputManager
import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager
import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.questEditor.stores.AreaStore
@ -57,7 +57,7 @@ class QuestEditor(
areaAssetLoader,
entityAssetLoader
),
EntityManipulator(questEditorStore, renderer)
UserInputManager(questEditorStore, renderer)
)
// Main Widget

View File

@ -19,6 +19,7 @@ import world.phantasmal.web.externals.babylon.Scene
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.models.SectionModel
import world.phantasmal.web.questEditor.rendering.CollisionMetadata
import world.phantasmal.webui.DisposableContainer
/**
@ -261,6 +262,7 @@ private fun areaCollisionGeometryToTransformNode(
if (builder.vertexCount > 0) {
val mesh = Mesh("Collision Geometry", scene, parent = node)
builder.build().applyToMesh(mesh)
mesh.metadata = CollisionMetadata()
}
}

View File

@ -2,17 +2,17 @@ package world.phantasmal.web.questEditor.rendering
import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.models.AreaVariantModel
private val logger = KotlinLogging.logger {}
class AreaMeshManager(private val areaAssetLoader: AreaAssetLoader) {
private var currentGeometry: TransformNode? = null
class AreaMeshManager(
private val renderer: QuestRenderer,
private val areaAssetLoader: AreaAssetLoader,
) {
suspend fun load(episode: Episode?, areaVariant: AreaVariantModel?) {
currentGeometry?.setEnabled(false)
renderer.collisionGeometry?.setEnabled(false)
if (episode == null || areaVariant == null) {
return
@ -21,7 +21,8 @@ class AreaMeshManager(private val areaAssetLoader: AreaAssetLoader) {
try {
val geom = areaAssetLoader.loadCollisionGeometry(episode, areaVariant)
geom.setEnabled(true)
currentGeometry = geom
renderer.collisionGeometry?.setEnabled(false)
renderer.collisionGeometry = geom
} catch (e: Exception) {
logger.error(e) {
"Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}."

View File

@ -11,7 +11,6 @@ import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.web.questEditor.models.WaveModel
import world.phantasmal.web.questEditor.rendering.conversion.EntityMetadata
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.DisposableContainer

View File

@ -1,5 +1,7 @@
package world.phantasmal.web.questEditor.rendering.conversion
package world.phantasmal.web.questEditor.rendering
import world.phantasmal.web.questEditor.models.QuestEntityModel
class EntityMetadata(val entity: QuestEntityModel<*, *>)
class CollisionMetadata

View File

@ -21,14 +21,14 @@ import world.phantasmal.web.questEditor.stores.QuestEditorStore
abstract class QuestMeshManager protected constructor(
private val scope: CoroutineScope,
questEditorStore: QuestEditorStore,
renderer: QuestRenderer,
private val renderer: QuestRenderer,
areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader,
) : TrackedDisposable() {
protected val disposer = Disposer()
private val areaDisposer = disposer.add(Disposer())
private val areaMeshManager = AreaMeshManager(areaAssetLoader)
private val areaMeshManager = AreaMeshManager(renderer, areaAssetLoader)
private val npcMeshManager = disposer.add(
EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader)
)
@ -51,6 +51,8 @@ abstract class QuestMeshManager protected constructor(
npcMeshManager.removeAll()
objectMeshManager.removeAll()
renderer.resetCamera()
// Load area model.
areaMeshManager.load(episode, areaVariant)

View File

@ -4,12 +4,16 @@ import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.externals.babylon.ArcRotateCamera
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.externals.babylon.Vector3
import kotlin.math.PI
import kotlin.math.max
class QuestRenderer(canvas: HTMLCanvasElement, engine: Engine) : Renderer(canvas, engine) {
override val camera = ArcRotateCamera("Camera", PI / 2, PI / 6, 500.0, Vector3.Zero(), scene)
var collisionGeometry: TransformNode? = null
init {
with(camera) {
attachControl(
@ -21,11 +25,38 @@ class QuestRenderer(canvas: HTMLCanvasElement, engine: Engine) : Renderer(canvas
inertia = 0.0
angularSensibilityX = 200.0
angularSensibilityY = 200.0
// Set lowerBetaLimit to avoid shitty camera implementation from breaking completely
// when looking directly down.
lowerBetaLimit = 0.4
panningInertia = 0.0
panningSensibility = 3.0
panningAxis = Vector3(1.0, 0.0, -1.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1
updatePanningSensibility()
onViewMatrixChangedObservable.add({ _, _ ->
updatePanningSensibility()
})
camera.storeState()
}
}
fun resetCamera() {
camera.restoreState()
}
override fun render() {
camera.minZ = max(0.01, camera.radius / 100)
camera.maxZ = max(2_000.0, 10 * camera.radius)
super.render()
}
/**
* Make "panningSensibility" an inverse function of radius to make panning work "sensibly"
* at all distances.
*/
private fun updatePanningSensibility() {
camera.panningSensibility = 1_000 / camera.radius
}
}

View File

@ -2,21 +2,20 @@ package world.phantasmal.web.questEditor.rendering
import kotlinx.browser.document
import mu.KotlinLogging
import org.w3c.dom.events.Event
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.web.core.minusAssign
import world.phantasmal.web.externals.babylon.AbstractMesh
import world.phantasmal.web.externals.babylon.Vector2
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.web.core.minus
import world.phantasmal.web.externals.babylon.*
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.rendering.conversion.EntityMetadata
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener
private val logger = KotlinLogging.logger {}
class EntityManipulator(
private val DOWN_VECTOR = Vector3.Down()
class UserInputManager(
private val questEditorStore: QuestEditorStore,
private val renderer: QuestRenderer,
) : DisposableContainer() {
@ -24,12 +23,13 @@ class EntityManipulator(
private val lastPointerPosition = Vector2.Zero()
private var movedSinceLastPointerDown = false
private var state: State
private var onPointerUpListener: Disposable? = null
/**
* Whether entity transformations, deletions, etc. are enabled or not.
* Hover over and selection still work when this is set to false.
*/
var enabled: Boolean = true
var entityManipulationEnabled: Boolean = true
set(enabled) {
field = enabled
state.cancel()
@ -37,7 +37,7 @@ class EntityManipulator(
}
init {
state = IdleState(questEditorStore, renderer, enabled)
state = IdleState(questEditorStore, renderer, entityManipulationEnabled)
observe(questEditorStore.selectedEntity) { state.cancel() }
@ -54,12 +54,11 @@ class EntityManipulator(
movedSinceLastPointerDown
))
document.addEventListener("pointerup", ::onPointerUp)
onPointerUpListener = disposableListener(document, "pointerup", ::onPointerUp)
}
private fun onPointerUp(e: Event) {
private fun onPointerUp(e: PointerEvent) {
try {
e as PointerEvent
processPointerEvent(e)
state = state.processEvent(PointerUpEvt(
@ -67,7 +66,8 @@ class EntityManipulator(
movedSinceLastPointerDown
))
} finally {
document.removeEventListener("pointerup", ::onPointerUp)
onPointerUpListener?.dispose()
onPointerUpListener = null
}
}
@ -112,16 +112,16 @@ private class Pick(
val mesh: AbstractMesh,
/**
* Vector that points from the grabbing point (somewhere on the model's surface) to the model's
* origin.
* Vector that points from the grabbing point (somewhere on the model's surface) to the entity's
* position.
*/
val grabOffset: Vector3,
/**
* Vector that points from the grabbing point to the terrain point directly under the model's
* origin.
* Vector that points from the grabbing point to the terrain point directly under the entity's
* position.
*/
// val dragAdjust: Vector3,
val dragAdjust: Vector3,
)
private abstract class State {
@ -141,7 +141,7 @@ private abstract class State {
private class IdleState(
private val questEditorStore: QuestEditorStore,
private val renderer: QuestRenderer,
private val enabled: Boolean,
private val entityManipulationEnabled: Boolean,
) : State() {
override fun processEvent(event: Evt): State =
when (event) {
@ -151,14 +151,14 @@ private class IdleState(
1 -> {
questEditorStore.setSelectedEntity(pick.entity)
if (enabled) {
if (entityManipulationEnabled) {
// TODO: Enter TranslationState.
}
}
2 -> {
questEditorStore.setSelectedEntity(pick.entity)
if (enabled) {
if (entityManipulationEnabled) {
// TODO: Enter RotationState.
}
}
@ -169,6 +169,8 @@ private class IdleState(
}
is PointerUpEvt -> {
updateCameraTarget()
// If the user clicks on nothing, deselect the currently selected entity.
if (!event.movedSinceLastPointerDown && pickEntity() == null) {
questEditorStore.setSelectedEntity(null)
@ -182,6 +184,14 @@ private class IdleState(
// Do nothing.
}
private fun updateCameraTarget() {
// If the user moved the camera, try setting the camera
// target to a better point.
pickGround()?.pickedPoint?.let { newTarget ->
renderer.camera.target = newTarget
}
}
private fun pickEntity(): Pick? {
// Find the nearest object and NPC under the pointer.
val pickInfo = renderer.scene.pick(renderer.scene.pointerX, renderer.scene.pointerY)
@ -189,27 +199,49 @@ private class IdleState(
val entity = (pickInfo.pickedMesh.metadata as? EntityMetadata)?.entity
?: return null
val grabOffset = pickInfo.pickedMesh.position.clone()
grabOffset -= pickInfo.pickedPoint!!
// TODO: dragAdjust.
// val dragAdjust = grabOffset.clone()
//
// // Find vertical distance to the ground.
// raycaster.set(intersection.object.position, DOWN_VECTOR)
// val [collision_geom_intersection] = raycaster.intersectObjects(
// this.renderer.collision_geometry.children,
// true,
// )
//
// if (collision_geom_intersection) {
// dragAdjust.y -= collision_geom_intersection.distance
// }
// Vector from the point where we grab the entity to its position.
val grabOffset = pickInfo.pickedMesh.position - pickInfo.pickedPoint!!
// 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.
renderer.scene.pickWithRay(
Ray(pickInfo.pickedMesh.position, DOWN_VECTOR),
{ it.metadata is CollisionMetadata },
)?.let { groundPick ->
dragAdjust.y -= groundPick.distance
}
return Pick(
entity,
pickInfo.pickedMesh,
grabOffset,
dragAdjust,
)
}
private fun pickGround(): PickingInfo? {
renderer.scene.multiPick(
renderer.engine.getRenderWidth() / 2,
renderer.engine.getRenderHeight() / 2,
{ it.metadata is CollisionMetadata },
renderer.camera,
)?.let { pickingInfoArray ->
// 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.
for (pickingInfo in pickingInfoArray) {
pickingInfo.getNormal()?.let { n ->
if (n.y > 0.75) {
return pickingInfo
}
}
}
}
return null
}
}

View File

@ -6,6 +6,7 @@ import org.w3c.dom.*
import org.w3c.dom.events.Event
import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.value
@ -34,6 +35,7 @@ class Menu<T : Any>(
private var highlightedIndex: Int? = null
private var highlightedElement: Element? = null
private var previouslyFocusedElement: Element? = null
private var onDocumentMouseDownListener: Disposable? = null
override fun Node.createElement() =
div {
@ -57,12 +59,14 @@ class Menu<T : Any>(
observe(this@Menu.hidden) {
if (it) {
document.removeEventListener("mousedown", ::onDocumentMouseDown)
onDocumentMouseDownListener?.dispose()
onDocumentMouseDownListener = null
clearHighlightItem()
(previouslyFocusedElement as HTMLElement?)?.focus()
} else {
document.addEventListener("mousedown", ::onDocumentMouseDown)
onDocumentMouseDownListener =
disposableListener(document, "mousedown", ::onDocumentMouseDown)
}
}
@ -76,7 +80,7 @@ class Menu<T : Any>(
}
override fun internalDispose() {
document.removeEventListener("mousedown", ::onDocumentMouseDown)
onDocumentMouseDownListener?.dispose()
super.internalDispose()
}

View File

@ -52,15 +52,15 @@ class Toolbar(
flex-direction: row;
align-items: center;
border-bottom: var(--pw-border);
padding: 0 2px;
padding: 3px 2px;
}
.pw-toolbar > * {
margin: 2px 1px;
margin: 0 1px;
}
.pw-toolbar > .pw-toolbar-group {
margin: 2px 3px;
margin: 0 3px;
display: flex;
flex-direction: row;
align-items: center;