Added minimal entity picking.

This commit is contained in:
Daan Vanden Bosch 2020-11-07 23:53:17 +01:00
parent 8de81c9cb4
commit 25f015dfbb
10 changed files with 392 additions and 108 deletions

View File

@ -6,6 +6,10 @@ import world.phantasmal.web.externals.babylon.Vector3
operator fun Vector3.minus(other: Vector3): Vector3 =
subtract(other)
operator fun Vector3.minusAssign(other: Vector3) {
subtractInPlace(other)
}
infix fun Vector3.dot(other: Vector3): Double =
Vector3.Dot(this, other)

View File

@ -8,20 +8,30 @@ import world.phantasmal.webui.DisposableContainer
private val logger = KotlinLogging.logger {}
abstract class Renderer(
protected val canvas: HTMLCanvasElement,
val canvas: HTMLCanvasElement,
protected val engine: Engine,
) : DisposableContainer() {
val scene = Scene(engine)
private val light = HemisphericLight("Light", Vector3(-1.0, 1.0, 1.0), scene)
private val light: HemisphericLight
protected abstract val camera: Camera
val scene = Scene(engine)
init {
with(scene) {
useRightHandedSystem = true
clearColor = Color4(0.09, 0.09, 0.09, 1.0)
}
light = HemisphericLight("Light", Vector3(-1.0, 1.0, 1.0), scene)
}
override fun internalDispose() {
camera.dispose()
light.dispose()
scene.dispose()
engine.dispose()
super.internalDispose()
}
fun startRendering() {
@ -34,14 +44,6 @@ abstract class Renderer(
engine.stopRenderLoop()
}
override fun internalDispose() {
camera.dispose()
light.dispose()
scene.dispose()
engine.dispose()
super.internalDispose()
}
private fun render() {
val lightDirection = Vector3(-1.0, 1.0, 1.0)
lightDirection.rotateByQuaternionToRef(camera.absoluteRotation, lightDirection)

View File

@ -12,6 +12,7 @@ external class Vector2(x: Double, y: Double) {
var x: Double
var y: Double
fun set(x: Double, y: Double): Vector2
fun addInPlace(otherVector: Vector2): Vector2
fun addInPlaceFromFloats(x: Double, y: Double): Vector2
fun subtract(otherVector: Vector2): Vector2
@ -32,10 +33,12 @@ external class Vector3(x: Double, y: Double, z: Double) {
var y: Double
var z: Double
fun set(x: Double, y: Double, z: Double): Vector2
fun toQuaternion(): Quaternion
fun addInPlace(otherVector: Vector3): Vector3
fun addInPlaceFromFloats(x: Double, y: Double, z: Double): Vector3
fun subtract(otherVector: Vector3): Vector3
fun subtractInPlace(otherVector: Vector3): Vector3
fun negate(): Vector3
fun negateInPlace(): Vector3
fun cross(other: Vector3): Vector3
@ -148,9 +151,25 @@ external class Engine(
antialias: Boolean = definedExternally,
) : ThinEngine
external class Ray
external class PickingInfo {
val bu: Double
val bv: Double
val distance: Double
val faceId: Int
val hit: Boolean
val originMesh: AbstractMesh?
val pickedMesh: AbstractMesh?
val pickedPoint: Vector3?
val ray: Ray?
}
external class Scene(engine: Engine) {
var useRightHandedSystem: Boolean
var clearColor: Color4
var pointerX: Double
var pointerY: Double
fun render()
fun addLight(light: Light)
@ -159,6 +178,15 @@ external class Scene(engine: Engine) {
fun removeLight(toRemove: Light)
fun removeMesh(toRemove: TransformNode, recursive: Boolean? = definedExternally)
fun removeTransformNode(toRemove: TransformNode)
fun pick(
x: Double,
y: Double,
predicate: (AbstractMesh) -> Boolean = definedExternally,
fastCheck: Boolean = definedExternally,
camera: Camera? = definedExternally,
trianglePredicate: (p0: Vector3, p1: Vector3, p2: Vector3, ray: Ray) -> Boolean = definedExternally,
): PickingInfo?
fun dispose()
}
@ -238,9 +266,13 @@ open external class TransformNode(
var rotationQuaternion: Quaternion?
val absoluteRotation: Quaternion
var scaling: Vector3
fun locallyTranslate(vector3: Vector3): TransformNode
}
abstract external class AbstractMesh : TransformNode {
var showBoundingBox: Boolean
fun getBoundingInfo(): BoundingInfo
}
@ -253,6 +285,9 @@ external class Mesh(
clonePhysicsImpostor: Boolean = definedExternally,
) : AbstractMesh {
fun createInstance(name: String): InstancedMesh
fun bakeCurrentTransformIntoVertices(
bakeIndependenlyOfChildren: Boolean = definedExternally,
): Mesh
}
external class InstancedMesh : AbstractMesh

View File

@ -14,6 +14,7 @@ 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.EntityManipulator
import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.QuestEditorStore
@ -48,13 +49,16 @@ class QuestEditor(
val npcCountsController = addDisposable(NpcCountsController(questEditorStore))
// Rendering
addDisposable(QuestEditorMeshManager(
addDisposables(
QuestEditorMeshManager(
scope,
questEditorStore,
renderer,
areaAssetLoader,
entityAssetLoader
))
),
EntityManipulator(questEditorStore, renderer)
)
// Main Widget
return QuestEditorWidget(

View File

@ -33,32 +33,33 @@ class EntityAssetLoader(
MeshBuilder.CreateCylinder(
"Entity",
obj {
diameter = 6.0
height = 20.0
diameter = 5.0
height = 18.0
},
scene
).apply {
setEnabled(false)
position = Vector3(0.0, 10.0, 0.0)
locallyTranslate(Vector3(0.0, 10.0, 0.0))
bakeCurrentTransformIntoVertices()
}
private val meshCache =
addDisposable(LoadingCache<Pair<EntityType, Int?>, Mesh> { it.dispose() })
override fun internalDispose() {
defaultMesh.dispose()
super.internalDispose()
}
suspend fun loadMesh(type: EntityType, model: Int?): Mesh =
meshCache.getOrPut(Pair(type, model)) {
scope.async {
try {
loadGeometry(type, model)?.let { vertexData ->
// TODO: Remove this check when XJ models are parsed.
if (vertexData.indices == null || vertexData.indices!!.length == 0) {
defaultMesh
} else {
val mesh = Mesh("${type.uniqueName}${model?.let { "-$it" }}", scene)
mesh.setEnabled(false)
vertexData.applyToMesh(mesh)
mesh
}
} ?: defaultMesh
} catch (e: Exception) {
logger.error(e) { "Couldn't load mesh for $type (model: $model)." }

View File

@ -0,0 +1,219 @@
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.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 questEditorStore: QuestEditorStore,
private val renderer: QuestRenderer,
) : DisposableContainer() {
private val pointerPosition = Vector2.Zero()
private val lastPointerPosition = Vector2.Zero()
private var movedSinceLastPointerDown = false
private var state: State
/**
* 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
set(enabled) {
field = enabled
state.cancel()
state = IdleState(questEditorStore, renderer, enabled)
}
init {
state = IdleState(questEditorStore, renderer, enabled)
observe(questEditorStore.selectedEntity, ::selectedEntityChanged)
addDisposables(
disposableListener(renderer.canvas, "pointerdown", ::onPointerDown)
)
}
private fun selectedEntityChanged(entity: QuestEntityModel<*, *>?) {
state.cancel()
}
private fun onPointerDown(e: PointerEvent) {
processPointerEvent(e)
state = state.processEvent(PointerDownEvt(
e.buttons.toInt(),
movedSinceLastPointerDown
))
document.addEventListener("pointerup", ::onPointerUp)
}
private fun onPointerUp(e: Event) {
try {
e as PointerEvent
processPointerEvent(e)
state = state.processEvent(PointerUpEvt(
e.buttons.toInt(),
movedSinceLastPointerDown
))
} finally {
document.removeEventListener("pointerup", ::onPointerUp)
}
}
private fun processPointerEvent(e: PointerEvent) {
val rect = renderer.canvas.getBoundingClientRect()
pointerPosition.set(e.clientX - rect.left, e.clientY - rect.top)
when (e.type) {
"pointerdown" -> {
movedSinceLastPointerDown = false
}
"pointermove", "pointerup" -> {
if (!pointerPosition.equals(lastPointerPosition)) {
movedSinceLastPointerDown = true
}
}
}
lastPointerPosition.copyFrom(pointerPosition)
}
}
private sealed class Evt
private sealed class PointerEvt : Evt() {
abstract val buttons: Int
abstract val movedSinceLastPointerDown: Boolean
}
private class PointerDownEvt(
override val buttons: Int,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt()
private class PointerUpEvt(
override val buttons: Int,
override val movedSinceLastPointerDown: Boolean,
) : PointerEvt()
private class Pick(
val entity: QuestEntityModel<*, *>,
val mesh: AbstractMesh,
/**
* Vector that points from the grabbing point (somewhere on the model's surface) to the model's
* origin.
*/
val grabOffset: Vector3,
/**
* Vector that points from the grabbing point to the terrain point directly under the model'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 questEditorStore: QuestEditorStore,
private val renderer: QuestRenderer,
private val enabled: Boolean,
) : State() {
override fun processEvent(event: Evt): State =
when (event) {
is PointerDownEvt -> {
pickEntity()?.let { pick ->
when (event.buttons) {
1 -> {
questEditorStore.setSelectedEntity(pick.entity)
if (enabled) {
// TODO: Enter TranslationState.
}
}
2 -> {
questEditorStore.setSelectedEntity(pick.entity)
if (enabled) {
// TODO: Enter RotationState.
}
}
}
}
this
}
is PointerUpEvt -> {
// If the user clicks on nothing, deselect the currently selected entity.
if (!event.movedSinceLastPointerDown && pickEntity() == null) {
questEditorStore.setSelectedEntity(null)
}
this
}
}
override fun cancel() {
// Do nothing.
}
private fun pickEntity(): Pick? {
// Find the nearest object and NPC under the pointer.
val pickInfo = renderer.scene.pick(renderer.scene.pointerX, renderer.scene.pointerY)
if (pickInfo?.pickedMesh == null) return null
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
// }
return Pick(
entity,
pickInfo.pickedMesh,
grabOffset,
)
}
}

View File

@ -3,31 +3,52 @@ package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mu.KotlinLogging
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.observable.value.Val
import world.phantasmal.web.externals.babylon.AbstractMesh
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
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
private val logger = KotlinLogging.logger {}
private class LoadedEntity(val entity: QuestEntityModel<*, *>, val disposer: Disposer)
class EntityMeshManager(
private val scope: CoroutineScope,
private val selectedWave: Val<WaveModel?>,
private val renderer: QuestRenderer,
private val questEditorStore: QuestEditorStore,
renderer: QuestRenderer,
private val entityAssetLoader: EntityAssetLoader,
) : TrackedDisposable() {
) : DisposableContainer() {
private val queue: MutableList<QuestEntityModel<*, *>> = mutableListOf()
private val loadedEntities: MutableList<LoadedEntity> = mutableListOf()
private val loadedEntities: MutableMap<QuestEntityModel<*, *>, LoadedEntity> = mutableMapOf()
private var loading = false
private var entityMeshes = TransformNode("Entities", renderer.scene)
private var hoveredMesh: AbstractMesh? = null
private var selectedMesh: AbstractMesh? = null
init {
observe(questEditorStore.selectedEntity) { entity ->
if (entity == null) {
unmarkSelected()
} else {
val loaded = loadedEntities[entity]
// Mesh might not be loaded yet.
if (loaded == null) {
unmarkSelected()
} else {
markSelected(loaded.mesh)
}
}
}
}
override fun internalDispose() {
entityMeshes.dispose()
removeAll()
super.internalDispose()
}
@ -63,26 +84,38 @@ class EntityMeshManager(
for (entity in entities) {
queue.remove(entity)
val loadedIndex = loadedEntities.indexOfFirst { it.entity == entity }
if (loadedIndex != -1) {
val loaded = loadedEntities.removeAt(loadedIndex)
renderer.removeEntityMesh(loaded.entity)
loaded.disposer.dispose()
}
loadedEntities.remove(entity)?.dispose()
}
}
fun removeAll() {
for (loaded in loadedEntities) {
loaded.disposer.dispose()
for (loaded in loadedEntities.values) {
loaded.dispose()
}
loadedEntities.clear()
queue.clear()
}
private fun markSelected(entityMesh: AbstractMesh) {
if (entityMesh == hoveredMesh) {
hoveredMesh = null
}
if (entityMesh != selectedMesh) {
selectedMesh?.let { it.showBoundingBox = false }
entityMesh.showBoundingBox = true
}
selectedMesh = entityMesh
}
private fun unmarkSelected() {
selectedMesh?.let { it.showBoundingBox = false }
selectedMesh = null
}
private suspend fun load(entity: QuestEntityModel<*, *>) {
// TODO
val mesh = entityAssetLoader.loadMesh(entity.type, model = null)
@ -90,20 +123,30 @@ class EntityMeshManager(
// Only add an instance of this mesh if the entity is still in the queue at this point.
if (queue.remove(entity)) {
val instance = mesh.createInstance(entity.type.uniqueName)
instance.metadata = EntityMetadata(entity)
instance.position = entity.worldPosition.value
updateEntityMesh(entity, instance)
instance.parent = entityMeshes
if (entity == questEditorStore.selectedEntity.value) {
markSelected(instance)
}
loadedEntities[entity] = LoadedEntity(entity, instance, questEditorStore.selectedWave)
}
}
}
private fun updateEntityMesh(entity: QuestEntityModel<*, *>, mesh: AbstractMesh) {
renderer.addEntityMesh(mesh)
private class LoadedEntity(
entity: QuestEntityModel<*, *>,
val mesh: AbstractMesh,
selectedWave: Val<WaveModel?>,
) : DisposableContainer() {
init {
mesh.metadata = EntityMetadata(entity)
val disposer = Disposer(
entity.worldPosition.observe { (pos) ->
observe(entity.worldPosition) { pos ->
mesh.position = pos
},
}
addDisposables(
// TODO: Rotation.
// entity.worldRotation.observe { (value) ->
// mesh.rotation.copy(value)
@ -118,17 +161,21 @@ class EntityMeshManager(
)
if (entity is QuestNpcModel) {
disposer.add(
addDisposable(
selectedWave
.map(entity.wave) { selectedWave, entityWave ->
selectedWave == null || selectedWave == entityWave
.map(entity.wave) { sWave, entityWave ->
sWave == null || sWave == entityWave
}
.observe(callNow = true) { (visible) ->
mesh.setEnabled(visible)
},
)
}
}
loadedEntities.add(LoadedEntity(entity, disposer))
override fun internalDispose() {
mesh.parent = null
mesh.dispose()
super.internalDispose()
}
}

View File

@ -30,10 +30,10 @@ abstract class QuestMeshManager protected constructor(
private val areaDisposer = disposer.add(Disposer())
private val areaMeshManager = AreaMeshManager(areaAssetLoader)
private val npcMeshManager = disposer.add(
EntityMeshManager(scope, questEditorStore.selectedWave, renderer, entityAssetLoader)
EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader)
)
private val objectMeshManager = disposer.add(
EntityMeshManager(scope, questEditorStore.selectedWave, renderer, entityAssetLoader)
EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader)
)
private var loadJob: Job? = null
@ -51,7 +51,6 @@ abstract class QuestMeshManager protected constructor(
areaDisposer.disposeAll()
npcMeshManager.removeAll()
objectMeshManager.removeAll()
renderer.resetEntityMeshes()
// Load entity meshes.
areaDisposer.addAll(

View File

@ -2,15 +2,12 @@ package world.phantasmal.web.questEditor.rendering
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.rendering.Renderer
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.externals.babylon.ArcRotateCamera
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.externals.babylon.Vector3
import kotlin.math.PI
class QuestRenderer(canvas: HTMLCanvasElement, engine: Engine) : Renderer(canvas, engine) {
private var entityMeshes = TransformNode("Entities", scene)
private val entityToMesh = mutableMapOf<QuestEntityModel<*, *>, AbstractMesh>()
override val camera = ArcRotateCamera("Camera", PI / 2, PI / 6, 500.0, Vector3.Zero(), scene)
init {
@ -31,41 +28,4 @@ class QuestRenderer(canvas: HTMLCanvasElement, engine: Engine) : Renderer(canvas
wheelDeltaPercentage = 0.1
}
}
override fun internalDispose() {
entityMeshes.dispose()
entityToMesh.clear()
super.internalDispose()
}
fun resetEntityMeshes() {
entityMeshes.dispose(false)
entityToMesh.clear()
entityMeshes = TransformNode("Entities", scene)
}
fun addEntityMesh(mesh: AbstractMesh) {
val entity = (mesh.metadata as EntityMetadata).entity
mesh.parent = entityMeshes
entityToMesh[entity]?.let { prevMesh ->
prevMesh.parent = null
prevMesh.dispose()
}
entityToMesh[entity] = mesh
// TODO: Mark selected entity.
// if (entity === this.selected_entity) {
// this.mark_selected(model)
// }
}
fun removeEntityMesh(entity: QuestEntityModel<*, *>) {
entityToMesh.remove(entity)?.let { mesh ->
mesh.parent = null
mesh.dispose()
}
}
}

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.models.WaveModel
import world.phantasmal.webui.stores.Store
@ -12,10 +13,12 @@ class QuestEditorStore(scope: CoroutineScope, private val areaStore: AreaStore)
private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null)
private val _selectedEntity = mutableVal<QuestEntityModel<*, *>?>(null)
val currentQuest: Val<QuestModel?> = _currentQuest
val currentArea: Val<AreaModel?> = _currentArea
val selectedWave: Val<WaveModel?> = _selectedWave
val selectedEntity: Val<QuestEntityModel<*, *>?> = _selectedEntity
// TODO: Take into account whether we're debugging or not.
val questEditingDisabled: Val<Boolean> = currentQuest.map { it == null }
@ -28,4 +31,14 @@ class QuestEditorStore(scope: CoroutineScope, private val areaStore: AreaStore)
_currentArea.value = areaStore.getArea(quest.episode, 0)
}
}
fun setSelectedEntity(entity: QuestEntityModel<*, *>?) {
entity?.let {
currentQuest.value?.let { quest ->
_currentArea.value = areaStore.getArea(quest.episode, entity.areaId)
}
}
_selectedEntity.value = entity
}
}