Converted from Babylon.js back to Three.js. Added texture viewer and entity textures.

This commit is contained in:
Daan Vanden Bosch 2020-11-23 21:54:35 +01:00
parent d98b565766
commit 2fac7dbc39
57 changed files with 2307 additions and 1520 deletions

View File

@ -270,5 +270,5 @@ class BufferCursor(
} }
} }
fun Buffer.cursor(): BufferCursor = fun Buffer.cursor(offset: Int = 0, size: Int = this.size - offset): BufferCursor =
BufferCursor(this) BufferCursor(this, offset, size)

View File

@ -16,17 +16,18 @@ class IffChunkHeader(val type: Int, val size: Int)
* IFF files contain chunks preceded by an 8-byte header. * IFF files contain chunks preceded by an 8-byte header.
* The header consists of 4 ASCII characters for the "Type ID" and a 32-bit integer specifying the chunk size. * The header consists of 4 ASCII characters for the "Type ID" and a 32-bit integer specifying the chunk size.
*/ */
fun parseIff(cursor: Cursor): PwResult<List<IffChunk>> = fun parseIff(cursor: Cursor, silent: Boolean = false): PwResult<List<IffChunk>> =
parse(cursor) { chunkCursor, type, size -> IffChunk(type, chunkCursor.take(size)) } parse(cursor, silent) { chunkCursor, type, size -> IffChunk(type, chunkCursor.take(size)) }
/** /**
* Parses just the chunk headers. * Parses just the chunk headers.
*/ */
fun parseIffHeaders(cursor: Cursor): PwResult<List<IffChunkHeader>> = fun parseIffHeaders(cursor: Cursor, silent: Boolean = false): PwResult<List<IffChunkHeader>> =
parse(cursor) { _, type, size -> IffChunkHeader(type, size) } parse(cursor, silent) { _, type, size -> IffChunkHeader(type, size) }
private fun <T> parse( private fun <T> parse(
cursor: Cursor, cursor: Cursor,
silent: Boolean,
getChunk: (Cursor, type: Int, size: Int) -> T, getChunk: (Cursor, type: Int, size: Int) -> T,
): PwResult<List<T>> { ): PwResult<List<T>> {
val result = PwResult.build<List<T>>(logger) val result = PwResult.build<List<T>>(logger)
@ -40,11 +41,14 @@ private fun <T> parse(
if (size > cursor.bytesLeft) { if (size > cursor.bytesLeft) {
corrupted = true corrupted = true
if (!silent) {
result.addProblem( result.addProblem(
if (chunks.isEmpty()) Severity.Error else Severity.Warning, if (chunks.isEmpty()) Severity.Error else Severity.Warning,
"IFF file corrupted.", "IFF file corrupted.",
"Size $size was too large (only ${cursor.bytesLeft} bytes left) at position $sizePos." "Size $size was too large (only ${cursor.bytesLeft} bytes left) at position $sizePos."
) )
}
break break
} }

View File

@ -67,9 +67,9 @@ class NjTriangleStrip(
val clockwiseWinding: Boolean, val clockwiseWinding: Boolean,
val hasTexCoords: Boolean, val hasTexCoords: Boolean,
val hasNormal: Boolean, val hasNormal: Boolean,
var textureId: UInt?, var textureId: Int?,
var srcAlpha: UByte?, var srcAlpha: Int?,
var dstAlpha: UByte?, var dstAlpha: Int?,
val vertices: List<NjMeshVertex>, val vertices: List<NjMeshVertex>,
) )
@ -84,11 +84,11 @@ sealed class NjChunk(val typeId: UByte) {
object Null : NjChunk(0u) object Null : NjChunk(0u)
class Bits(typeId: UByte, val srcAlpha: UByte, val dstAlpha: UByte) : NjChunk(typeId) class Bits(typeId: UByte, val srcAlpha: Int, val dstAlpha: Int) : NjChunk(typeId)
class CachePolygonList(val cacheIndex: UByte, val offset: Int) : NjChunk(4u) class CachePolygonList(val cacheIndex: Int, val offset: Int) : NjChunk(4u)
class DrawPolygonList(val cacheIndex: UByte) : NjChunk(5u) class DrawPolygonList(val cacheIndex: Int) : NjChunk(5u)
class Tiny( class Tiny(
typeId: UByte, typeId: UByte,
@ -97,15 +97,15 @@ sealed class NjChunk(val typeId: UByte) {
val clampU: Boolean, val clampU: Boolean,
val clampV: Boolean, val clampV: Boolean,
val mipmapDAdjust: UInt, val mipmapDAdjust: UInt,
val filterMode: UInt, val filterMode: Int,
val superSample: Boolean, val superSample: Boolean,
val textureId: UInt, val textureId: Int,
) : NjChunk(typeId) ) : NjChunk(typeId)
class Material( class Material(
typeId: UByte, typeId: UByte,
val srcAlpha: UByte, val srcAlpha: Int,
val dstAlpha: UByte, val dstAlpha: Int,
val diffuse: NjArgb?, val diffuse: NjArgb?,
val ambient: NjArgb?, val ambient: NjArgb?,
val specular: NjErgb?, val specular: NjErgb?,

View File

@ -13,11 +13,9 @@ import kotlin.math.abs
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private const val ZERO_U8: UByte = 0u
// TODO: Simplify parser by not parsing chunks into vertices and meshes. Do the chunk to vertex/mesh // TODO: Simplify parser by not parsing chunks into vertices and meshes. Do the chunk to vertex/mesh
// conversion at a higher level. // conversion at a higher level.
fun parseNjModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>): NjModel { fun parseNjModel(cursor: Cursor, cachedChunkOffsets: MutableMap<Int, Int>): NjModel {
val vlistOffset = cursor.int() // Vertex list val vlistOffset = cursor.int() // Vertex list
val plistOffset = cursor.int() // Triangle strip index list val plistOffset = cursor.int() // Triangle strip index list
val collisionSphereCenter = cursor.vec3Float() val collisionSphereCenter = cursor.vec3Float()
@ -50,9 +48,9 @@ fun parseNjModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>): Nj
if (plistOffset != 0) { if (plistOffset != 0) {
cursor.seekStart(plistOffset) cursor.seekStart(plistOffset)
var textureId: UInt? = null var textureId: Int? = null
var srcAlpha: UByte? = null var srcAlpha: Int? = null
var dstAlpha: UByte? = null var dstAlpha: Int? = null
for (chunk in parseChunks(cursor, cachedChunkOffsets, false)) { for (chunk in parseChunks(cursor, cachedChunkOffsets, false)) {
when (chunk) { when (chunk) {
@ -98,7 +96,7 @@ fun parseNjModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>): Nj
// TODO: don't reparse when DrawPolygonList chunk is encountered. // TODO: don't reparse when DrawPolygonList chunk is encountered.
private fun parseChunks( private fun parseChunks(
cursor: Cursor, cursor: Cursor,
cachedChunkOffsets: MutableMap<UByte, Int>, cachedChunkOffsets: MutableMap<Int, Int>,
wideEndChunks: Boolean, wideEndChunks: Boolean,
): List<NjChunk> { ): List<NjChunk> {
val chunks: MutableList<NjChunk> = mutableListOf() val chunks: MutableList<NjChunk> = mutableListOf()
@ -106,8 +104,7 @@ private fun parseChunks(
while (loop) { while (loop) {
val typeId = cursor.uByte() val typeId = cursor.uByte()
val flags = cursor.uByte() val flags = cursor.uByte().toInt()
val flagsUInt = flags.toUInt()
val chunkStartPosition = cursor.position val chunkStartPosition = cursor.position
var size = 0 var size = 0
@ -118,8 +115,8 @@ private fun parseChunks(
in 1..3 -> { in 1..3 -> {
chunks.add(NjChunk.Bits( chunks.add(NjChunk.Bits(
typeId, typeId,
srcAlpha = ((flagsUInt shr 3).toUByte() and 0b111u), srcAlpha = (flags ushr 3) and 0b111,
dstAlpha = flags and 0b111u, dstAlpha = flags and 0b111,
)) ))
} }
4 -> { 4 -> {
@ -147,7 +144,7 @@ private fun parseChunks(
} }
in 8..9 -> { in 8..9 -> {
size = 2 size = 2
val textureBitsAndId = cursor.uShort().toUInt() val textureBitsAndId = cursor.uShort().toInt()
chunks.add(NjChunk.Tiny( chunks.add(NjChunk.Tiny(
typeId, typeId,
@ -156,9 +153,9 @@ private fun parseChunks(
clampU = (typeId.toUInt() and 0x20u) != 0u, clampU = (typeId.toUInt() and 0x20u) != 0u,
clampV = (typeId.toUInt() and 0x10u) != 0u, clampV = (typeId.toUInt() and 0x10u) != 0u,
mipmapDAdjust = typeId.toUInt() and 0b1111u, mipmapDAdjust = typeId.toUInt() and 0b1111u,
filterMode = textureBitsAndId shr 14, filterMode = textureBitsAndId ushr 14,
superSample = (textureBitsAndId and 0x40u) != 0u, superSample = (textureBitsAndId and 0x40) != 0,
textureId = textureBitsAndId and 0x1fffu, textureId = textureBitsAndId and 0x1fff,
)) ))
} }
in 17..31 -> { in 17..31 -> {
@ -168,7 +165,7 @@ private fun parseChunks(
var ambient: NjArgb? = null var ambient: NjArgb? = null
var specular: NjErgb? = null var specular: NjErgb? = null
if ((flagsUInt and 0b1u) != 0u) { if ((flags and 0b1) != 0) {
diffuse = NjArgb( diffuse = NjArgb(
b = cursor.uByte().toFloat() / 255f, b = cursor.uByte().toFloat() / 255f,
g = cursor.uByte().toFloat() / 255f, g = cursor.uByte().toFloat() / 255f,
@ -177,7 +174,7 @@ private fun parseChunks(
) )
} }
if ((flagsUInt and 0b10u) != 0u) { if ((flags and 0b10) != 0) {
ambient = NjArgb( ambient = NjArgb(
b = cursor.uByte().toFloat() / 255f, b = cursor.uByte().toFloat() / 255f,
g = cursor.uByte().toFloat() / 255f, g = cursor.uByte().toFloat() / 255f,
@ -186,7 +183,7 @@ private fun parseChunks(
) )
} }
if ((flagsUInt and 0b100u) != 0u) { if ((flags and 0b100) != 0) {
specular = NjErgb( specular = NjErgb(
b = cursor.uByte(), b = cursor.uByte(),
g = cursor.uByte(), g = cursor.uByte(),
@ -197,8 +194,8 @@ private fun parseChunks(
chunks.add(NjChunk.Material( chunks.add(NjChunk.Material(
typeId, typeId,
srcAlpha = ((flagsUInt shr 3).toUByte() and 0b111u), srcAlpha = (flags ushr 3) and 0b111,
dstAlpha = flags and 0b111u, dstAlpha = flags and 0b111,
diffuse, diffuse,
ambient, ambient,
specular, specular,
@ -247,10 +244,10 @@ private fun parseChunks(
private fun parseVertexChunk( private fun parseVertexChunk(
cursor: Cursor, cursor: Cursor,
chunkTypeId: UByte, chunkTypeId: UByte,
flags: UByte, flags: Int,
): List<NjChunkVertex> { ): List<NjChunkVertex> {
val boneWeightStatus = (flags and 0b11u).toInt() val boneWeightStatus = flags and 0b11
val calcContinue = (flags and 0x80u) != ZERO_U8 val calcContinue = (flags and 0x80) != 0
val index = cursor.uShort() val index = cursor.uShort()
val vertexCount = cursor.uShort() val vertexCount = cursor.uShort()
@ -333,15 +330,15 @@ private fun parseVertexChunk(
private fun parseTriangleStripChunk( private fun parseTriangleStripChunk(
cursor: Cursor, cursor: Cursor,
chunkTypeId: UByte, chunkTypeId: UByte,
flags: UByte, flags: Int,
): List<NjTriangleStrip> { ): List<NjTriangleStrip> {
val ignoreLight = (flags and 0b1u) != ZERO_U8 val ignoreLight = (flags and 0b1) != 0
val ignoreSpecular = (flags and 0b10u) != ZERO_U8 val ignoreSpecular = (flags and 0b10) != 0
val ignoreAmbient = (flags and 0b100u) != ZERO_U8 val ignoreAmbient = (flags and 0b100) != 0
val useAlpha = (flags and 0b1000u) != ZERO_U8 val useAlpha = (flags and 0b1000) != 0
val doubleSide = (flags and 0b10000u) != ZERO_U8 val doubleSide = (flags and 0b10000) != 0
val flatShading = (flags and 0b100000u) != ZERO_U8 val flatShading = (flags and 0b100000) != 0
val environmentMapping = (flags and 0b1000000u) != ZERO_U8 val environmentMapping = (flags and 0b1000000) != 0
val userOffsetAndStripCount = cursor.short().toInt() val userOffsetAndStripCount = cursor.short().toInt()
val userFlagsSize = (userOffsetAndStripCount ushr 14) val userFlagsSize = (userOffsetAndStripCount ushr 14)

View File

@ -0,0 +1,103 @@
package world.phantasmal.lib.fileFormats.ninja
import mu.KotlinLogging
import world.phantasmal.core.Failure
import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity
import world.phantasmal.core.Success
import world.phantasmal.lib.buffer.Buffer
import world.phantasmal.lib.cursor.Cursor
import world.phantasmal.lib.fileFormats.parseIff
import world.phantasmal.lib.fileFormats.parseIffHeaders
private val logger = KotlinLogging.logger {}
private const val XVMH = 0x484d5658
private const val XVRT = 0x54525658
class Xvm(
val textures: List<XvrTexture>,
)
class XvrTexture(
val id: Int,
val format: Pair<Int, Int>,
val width: Int,
val height: Int,
val size: Int,
val data: Buffer,
)
fun parseXvr(cursor: Cursor): XvrTexture {
val format1 = cursor.int()
val format2 = cursor.int()
val id = cursor.int()
val width = cursor.uShort().toInt()
val height = cursor.uShort().toInt()
val size = cursor.int()
cursor.seek(36)
val data = cursor.buffer(size)
return XvrTexture(
id,
format = Pair(format1, format2),
width,
height,
size,
data,
)
}
fun isXvm(cursor: Cursor): Boolean {
val iffResult = parseIffHeaders(cursor, silent = true)
cursor.seekStart(0)
return iffResult is Success &&
iffResult.value.any { chunk -> chunk.type == XVMH || chunk.type == XVRT }
}
fun parseXvm(cursor: Cursor): PwResult<Xvm> {
val iffResult = parseIff(cursor)
if (iffResult !is Success) {
return iffResult as Failure
}
val result = PwResult.build<Xvm>(logger)
result.addResult(iffResult)
val chunks = iffResult.value
val headerChunk = chunks.find { it.type == XVMH }
val header = headerChunk?.data?.let(::parseHeader)
val textures = chunks
.filter { it.type == XVRT }
.map { parseXvr(it.data) }
if (header == null && textures.isEmpty()) {
result.addProblem(
Severity.Error,
"Corrupted XVM file.",
"No header and no XVRT chunks found.",
)
return result.failure()
}
if (header != null && header.textureCount != textures.size) {
result.addProblem(
Severity.Warning,
"Corrupted XVM file.",
"Found ${textures.size} textures instead of ${header.textureCount} as defined in the header.",
)
}
return result.success(Xvm(textures))
}
private class Header(
val textureCount: Int,
)
private fun parseHeader(cursor: Cursor): Header {
val textureCount = cursor.uShort().toInt()
return Header(textureCount)
}

View File

@ -15,4 +15,11 @@ interface QuestEntity<Type : EntityType> {
var position: Vec3 var position: Vec3
var rotation: Vec3 var rotation: Vec3
/**
* Set the section-relative position.
*/
fun setPosition(x: Float, y: Float, z: Float)
fun setRotation(x: Float, y: Float, z: Float)
} }

View File

@ -78,9 +78,7 @@ class QuestNpc(
override var position: Vec3 override var position: Vec3
get() = Vec3(data.getFloat(20), data.getFloat(24), data.getFloat(28)) get() = Vec3(data.getFloat(20), data.getFloat(24), data.getFloat(28))
set(value) { set(value) {
data.setFloat(20, value.x) setPosition(value.x, value.y, value.z)
data.setFloat(24, value.y)
data.setFloat(28, value.z)
} }
override var rotation: Vec3 override var rotation: Vec3
@ -90,9 +88,7 @@ class QuestNpc(
angleToRad(data.getInt(40)), angleToRad(data.getInt(40)),
) )
set(value) { set(value) {
data.setInt(32, radToAngle(value.x)) setRotation(value.x, value.y, value.z)
data.setInt(36, radToAngle(value.y))
data.setInt(40, radToAngle(value.z))
} }
/** /**
@ -121,4 +117,16 @@ class QuestNpc(
"Data size should be $NPC_BYTE_SIZE but was ${data.size}." "Data size should be $NPC_BYTE_SIZE but was ${data.size}."
} }
} }
override fun setPosition(x: Float, y: Float, z: Float) {
data.setFloat(20, x)
data.setFloat(24, y)
data.setFloat(28, z)
}
override fun setRotation(x: Float, y: Float, z: Float) {
data.setInt(32, radToAngle(x))
data.setInt(36, radToAngle(y))
data.setInt(40, radToAngle(z))
}
} }

View File

@ -28,9 +28,7 @@ class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<Obje
override var position: Vec3 override var position: Vec3
get() = Vec3(data.getFloat(16), data.getFloat(20), data.getFloat(24)) get() = Vec3(data.getFloat(16), data.getFloat(20), data.getFloat(24))
set(value) { set(value) {
data.setFloat(16, value.x) setPosition(value.x, value.y, value.z)
data.setFloat(20, value.y)
data.setFloat(24, value.z)
} }
override var rotation: Vec3 override var rotation: Vec3
@ -40,9 +38,7 @@ class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<Obje
angleToRad(data.getInt(36)), angleToRad(data.getInt(36)),
) )
set(value) { set(value) {
data.setInt(28, radToAngle(value.x)) setRotation(value.x, value.y, value.z)
data.setInt(32, radToAngle(value.y))
data.setInt(36, radToAngle(value.z))
} }
val scriptLabel: Int? val scriptLabel: Int?
@ -96,4 +92,16 @@ class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<Obje
"Data size should be $OBJECT_BYTE_SIZE but was ${data.size}." "Data size should be $OBJECT_BYTE_SIZE but was ${data.size}."
} }
} }
override fun setPosition(x: Float, y: Float, z: Float) {
data.setFloat(16, x)
data.setFloat(20, y)
data.setFloat(24, z)
}
override fun setRotation(x: Float, y: Float, z: Float) {
data.setInt(28, radToAngle(x))
data.setInt(32, radToAngle(y))
data.setInt(36, radToAngle(z))
}
} }

View File

@ -7,10 +7,13 @@ import org.khronos.webgl.Uint8Array
import world.phantasmal.lib.Endianness import world.phantasmal.lib.Endianness
actual class Buffer private constructor( actual class Buffer private constructor(
private var arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
size: Int, size: Int,
endianness: Endianness, endianness: Endianness,
) { ) {
var arrayBuffer = arrayBuffer
private set
private var dataView = DataView(arrayBuffer) private var dataView = DataView(arrayBuffer)
private var littleEndian = endianness == Endianness.Little private var littleEndian = endianness == Endianness.Little

View File

@ -40,9 +40,9 @@ dependencies {
implementation("io.ktor:ktor-client-core-js:$ktorVersion") implementation("io.ktor:ktor-client-core-js:$ktorVersion")
implementation("io.ktor:ktor-client-serialization-js:$ktorVersion") implementation("io.ktor:ktor-client-serialization-js:$ktorVersion")
implementation("org.jetbrains.kotlin:kotlin-serialization:$serializationVersion") implementation("org.jetbrains.kotlin:kotlin-serialization:$serializationVersion")
implementation(npm("@babylonjs/core", "^4.2.0"))
implementation(npm("golden-layout", "^1.5.9")) implementation(npm("golden-layout", "^1.5.9"))
implementation(npm("monaco-editor", "^0.21.2")) implementation(npm("monaco-editor", "^0.21.2"))
implementation(npm("three", "^0.122.0"))
implementation(devNpm("file-loader", "^6.0.0")) implementation(devNpm("file-loader", "^6.0.0"))
implementation(devNpm("monaco-editor-webpack-plugin", "^2.0.0")) implementation(devNpm("monaco-editor-webpack-plugin", "^2.0.0"))

View File

@ -18,10 +18,14 @@ import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.application.Application import world.phantasmal.web.application.Application
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.logging.LogAppender
import world.phantasmal.web.core.logging.LogFormatter
import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.stores.ApplicationUrl import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.externals.three.WebGLRenderer
import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.dom.root import world.phantasmal.webui.dom.root
import world.phantasmal.webui.obj
fun main() { fun main() {
if (document.body != null) { if (document.body != null) {
@ -33,6 +37,7 @@ fun main() {
private fun init(): Disposable { private fun init(): Disposable {
KotlinLoggingConfiguration.FORMATTER = LogFormatter() KotlinLoggingConfiguration.FORMATTER = LogFormatter()
KotlinLoggingConfiguration.APPENDER = LogAppender()
if (window.location.hostname == "localhost") { if (window.location.hostname == "localhost") {
KotlinLoggingConfiguration.LOG_LEVEL = KotlinLoggingLevel.TRACE KotlinLoggingConfiguration.LOG_LEVEL = KotlinLoggingLevel.TRACE
@ -60,14 +65,31 @@ private fun init(): Disposable {
rootElement, rootElement,
AssetLoader(httpClient), AssetLoader(httpClient),
disposer.add(HistoryApplicationUrl()), disposer.add(HistoryApplicationUrl()),
createEngine = { Engine(it) } ::createThreeRenderer,
) )
) )
return disposer return disposer
} }
class HistoryApplicationUrl : TrackedDisposable(), ApplicationUrl { private fun createThreeRenderer(): DisposableThreeRenderer =
object : TrackedDisposable(), DisposableThreeRenderer {
override val renderer = WebGLRenderer(obj {
antialias = true
alpha = true
})
init {
renderer.setPixelRatio(window.devicePixelRatio)
}
override fun internalDispose() {
renderer.dispose()
super.internalDispose()
}
}
private class HistoryApplicationUrl : TrackedDisposable(), ApplicationUrl {
private val path: String get() = window.location.pathname private val path: String get() = window.location.pathname
override val url = mutableVal(window.location.hash.substring(1)) override val url = mutableVal(window.location.hash.substring(1))

View File

@ -3,7 +3,6 @@ package world.phantasmal.web.application
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.DragEvent import org.w3c.dom.DragEvent
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.events.Event import org.w3c.dom.events.Event
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
@ -14,9 +13,9 @@ import world.phantasmal.web.application.widgets.MainContentWidget
import world.phantasmal.web.application.widgets.NavigationWidget import world.phantasmal.web.application.widgets.NavigationWidget
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.stores.ApplicationUrl import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.huntOptimizer.HuntOptimizer import world.phantasmal.web.huntOptimizer.HuntOptimizer
import world.phantasmal.web.questEditor.QuestEditor import world.phantasmal.web.questEditor.QuestEditor
import world.phantasmal.web.viewer.Viewer import world.phantasmal.web.viewer.Viewer
@ -28,7 +27,7 @@ class Application(
rootElement: HTMLElement, rootElement: HTMLElement,
assetLoader: AssetLoader, assetLoader: AssetLoader,
applicationUrl: ApplicationUrl, applicationUrl: ApplicationUrl,
createEngine: (HTMLCanvasElement) -> Engine, createThreeRenderer: () -> DisposableThreeRenderer,
) : DisposableContainer() { ) : DisposableContainer() {
init { init {
addDisposables( addDisposables(
@ -49,8 +48,8 @@ class Application(
// The various tools Phantasmal World consists of. // The various tools Phantasmal World consists of.
val tools: List<PwTool> = listOf( val tools: List<PwTool> = listOf(
Viewer(createEngine), Viewer(createThreeRenderer),
QuestEditor(assetLoader, uiStore, createEngine), QuestEditor(assetLoader, uiStore, createThreeRenderer),
HuntOptimizer(assetLoader, uiStore), HuntOptimizer(assetLoader, uiStore),
) )

View File

@ -1,74 +0,0 @@
package world.phantasmal.web.core
import world.phantasmal.web.externals.babylon.Matrix
import world.phantasmal.web.externals.babylon.Quaternion
import world.phantasmal.web.externals.babylon.Vector3
operator fun Vector3.plus(other: Vector3): Vector3 =
add(other)
operator fun Vector3.plusAssign(other: Vector3) {
addInPlace(other)
}
operator fun Vector3.minus(other: Vector3): Vector3 =
subtract(other)
operator fun Vector3.minusAssign(other: Vector3) {
subtractInPlace(other)
}
operator fun Vector3.times(scalar: Double): Vector3 =
scale(scalar)
infix fun Vector3.dot(other: Vector3): Double =
Vector3.Dot(this, other)
infix fun Vector3.cross(other: Vector3): Vector3 =
cross(other)
operator fun Matrix.timesAssign(other: Matrix) {
other.preMultiply(this)
}
fun Matrix.preMultiply(other: Matrix) {
// Multiplies this by other.
multiplyToRef(other, this)
}
fun Matrix.multiply(v: Vector3) {
Vector3.TransformCoordinatesToRef(v, this, v)
}
fun Matrix.multiply3x3(v: Vector3) {
Vector3.TransformNormalToRef(v, this, v)
}
operator fun Quaternion.timesAssign(other: Quaternion) {
multiplyInPlace(other)
}
/**
* Returns a new quaternion that's the inverse of this quaternion.
*/
fun Quaternion.inverse(): Quaternion = Quaternion.Inverse(this)
/**
* Inverts this quaternion.
*/
fun Quaternion.invert() {
Quaternion.InverseToRef(this, this)
}
/**
* Transforms [p] by this versor.
*/
fun Quaternion.transform(p: Vector3) {
p.rotateByQuaternionToRef(this, p)
}
/**
* Returns a new point equal to [p] transformed by this versor.
*/
fun Quaternion.transformed(p: Vector3): Vector3 =
p.rotateByQuaternionToRef(this, Vector3.Zero())

View File

@ -0,0 +1,56 @@
package world.phantasmal.web.core
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Quaternion
import world.phantasmal.web.externals.three.Vector3
operator fun Vector3.plus(other: Vector3): Vector3 =
clone().add(other)
operator fun Vector3.plusAssign(other: Vector3) {
add(other)
}
operator fun Vector3.minus(other: Vector3): Vector3 =
clone().sub(other)
operator fun Vector3.minusAssign(other: Vector3) {
sub(other)
}
operator fun Vector3.times(scalar: Double): Vector3 =
clone().multiplyScalar(scalar)
infix fun Vector3.dot(other: Vector3): Double =
dot(other)
infix fun Vector3.cross(other: Vector3): Vector3 =
cross(other)
operator fun Quaternion.timesAssign(other: Quaternion) {
multiply(other)
}
/**
* Creates an [Euler] object from a [Quaternion] with the correct rotation order.
*/
fun Quaternion.toEuler(): Euler =
Euler().setFromQuaternion(this, "ZXY")
/**
* Creates an [Euler] object with the correct rotation order.
*/
fun euler(x: Float, y: Float, z: Float): Euler =
euler(x.toDouble(), y.toDouble(), z.toDouble())
/**
* Creates an [Euler] object with the correct rotation order.
*/
fun euler(x: Double, y: Double, z: Double): Euler =
Euler(x, y, z, "ZXY")
/**
* Creates an [Euler] object from a [Quaternion] with the correct rotation order.
*/
fun Euler.toQuaternion(): Quaternion =
Quaternion().setFromEuler(this)

View File

@ -0,0 +1,45 @@
package world.phantasmal.web.core.logging
import mu.Appender
class LogAppender : Appender {
override fun trace(message: Any?) {
if (message is MessageWithThrowable) {
console.log(message.message, message.throwable)
} else {
console.log(message)
}
}
override fun debug(message: Any?) {
if (message is MessageWithThrowable) {
console.log(message.message, message.throwable)
} else {
console.log(message)
}
}
override fun info(message: Any?) {
if (message is MessageWithThrowable) {
console.info(message.message, message.throwable)
} else {
console.info(message)
}
}
override fun warn(message: Any?) {
if (message is MessageWithThrowable) {
console.warn(message.message, message.throwable)
} else {
console.warn(message)
}
}
override fun error(message: Any?) {
if (message is MessageWithThrowable) {
console.error(message.message, message.throwable)
} else {
console.error(message)
}
}
}

View File

@ -1,6 +1,5 @@
package world.phantasmal.web package world.phantasmal.web.core.logging
import mu.DefaultMessageFormatter
import mu.Formatter import mu.Formatter
import mu.KotlinLoggingLevel import mu.KotlinLoggingLevel
import mu.Marker import mu.Marker
@ -12,15 +11,15 @@ class LogFormatter : Formatter {
loggerName: String, loggerName: String,
msg: () -> Any?, msg: () -> Any?,
): String = ): String =
time() + DefaultMessageFormatter.formatMessage(level, loggerName, msg) "${time()} ${level.str()} $loggerName - ${msg.toStringSafe()}"
override fun formatMessage( override fun formatMessage(
level: KotlinLoggingLevel, level: KotlinLoggingLevel,
loggerName: String, loggerName: String,
t: Throwable?, t: Throwable?,
msg: () -> Any?, msg: () -> Any?,
): String = ): MessageWithThrowable =
time() + DefaultMessageFormatter.formatMessage(level, loggerName, t, msg) MessageWithThrowable(formatMessage(level, loggerName, msg), t)
override fun formatMessage( override fun formatMessage(
level: KotlinLoggingLevel, level: KotlinLoggingLevel,
@ -28,7 +27,7 @@ class LogFormatter : Formatter {
marker: Marker?, marker: Marker?,
msg: () -> Any?, msg: () -> Any?,
): String = ): String =
time() + DefaultMessageFormatter.formatMessage(level, loggerName, marker, msg) "${time()} ${level.str()} $loggerName [${marker?.getName()}] - ${msg.toStringSafe()}"
override fun formatMessage( override fun formatMessage(
level: KotlinLoggingLevel, level: KotlinLoggingLevel,
@ -36,8 +35,20 @@ class LogFormatter : Formatter {
marker: Marker?, marker: Marker?,
t: Throwable?, t: Throwable?,
msg: () -> Any?, msg: () -> Any?,
): String = ): MessageWithThrowable =
time() + DefaultMessageFormatter.formatMessage(level, loggerName, marker, t, msg) MessageWithThrowable(formatMessage(level, loggerName, marker, msg), t)
@Suppress("NOTHING_TO_INLINE")
private inline fun (() -> Any?).toStringSafe(): String {
return try {
invoke().toString()
} catch (e: Exception) {
"Log message invocation failed: $e"
}
}
private fun KotlinLoggingLevel.str(): String =
name.padEnd(MIN_LEVEL_LEN)
private fun time(): String { private fun time(): String {
val date = Date() val date = Date()
@ -47,4 +58,9 @@ class LogFormatter : Formatter {
val ms = date.getMilliseconds().toString().padStart(3, '0') val ms = date.getMilliseconds().toString().padStart(3, '0')
return "$h:$m:$s.$ms " return "$h:$m:$s.$ms "
} }
companion object {
private val MIN_LEVEL_LEN: Int =
KotlinLoggingLevel.values().map { it.name.length }.maxOrNull()!!
}
} }

View File

@ -0,0 +1,6 @@
package world.phantasmal.web.core.logging
class MessageWithThrowable(
val message: Any?,
val throwable: Throwable?,
)

View File

@ -0,0 +1,27 @@
package world.phantasmal.web.core.rendering
import world.phantasmal.web.externals.three.Object3D
/**
* Recursively disposes any geometries/materials/textures attached to the given [Object3D] or its
* children.
*/
fun disposeObject3DResources(obj: Object3D) {
val dynObj = obj.asDynamic()
dynObj.geometry?.dispose()
if (dynObj.material is Array<*>) {
for (material in dynObj.material) {
material.map?.dispose()
material.dispose()
}
} else if (dynObj.material != null) {
dynObj.material.map?.dispose()
dynObj.material.dispose()
}
for (child in obj.children) {
disposeObject3DResources(child)
}
}

View File

@ -1,53 +1,115 @@
package world.phantasmal.web.core.rendering package world.phantasmal.web.core.rendering
import kotlinx.browser.window
import mu.KotlinLogging import mu.KotlinLogging
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.externals.babylon.* import world.phantasmal.core.disposable.Disposable
import world.phantasmal.web.core.minus
import world.phantasmal.web.externals.three.*
import world.phantasmal.webui.DisposableContainer 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 {} private val logger = KotlinLogging.logger {}
interface DisposableThreeRenderer : Disposable {
val renderer: ThreeRenderer
}
abstract class Renderer( abstract class Renderer(
val canvas: HTMLCanvasElement, createThreeRenderer: () -> DisposableThreeRenderer,
val engine: Engine, val camera: Camera,
) : DisposableContainer() { ) : DisposableContainer() {
private val light: HemisphericLight 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 val camera: Camera private var rendering = false
private var animationFrameHandle: Int = 0
val scene = Scene(engine) val canvas: HTMLCanvasElement =
threeRenderer.domElement.apply {
init { tabIndex = 0
with(scene) { style.outline = "none"
useRightHandedSystem = true
clearColor = Color4.FromInts(0x18, 0x18, 0x18, 0xFF)
} }
light = HemisphericLight("Light", Vector3(-1.0, 1.0, 1.0), scene) val scene: Scene =
Scene().apply {
background = Color(0x181818)
add(lightHolder)
} }
override fun internalDispose() { val controls: OrbitControls =
camera.dispose() OrbitControls(camera, canvas).apply {
light.dispose() mouseButtons = obj {
scene.dispose() LEFT = MOUSE.PAN
engine.dispose() MIDDLE = MOUSE.DOLLY
super.internalDispose() RIGHT = MOUSE.ROTATE
}
} }
fun startRendering() { fun startRendering() {
logger.trace { "${this::class.simpleName} - start rendering." } logger.trace { "${this::class.simpleName} - start rendering." }
engine.runRenderLoop(::render)
if (!rendering) {
rendering = true
renderLoop()
}
} }
fun stopRendering() { fun stopRendering() {
logger.trace { "${this::class.simpleName} - stop rendering." } logger.trace { "${this::class.simpleName} - stop rendering." }
engine.stopRenderLoop()
rendering = false
window.cancelAnimationFrame(animationFrameHandle)
}
open fun setSize(width: Double, height: Double) {
canvas.width = floor(width).toInt()
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()
} }
protected open fun render() { protected open fun render() {
val lightDirection = Vector3(-1.0, 1.0, 1.0) if (camera is PerspectiveCamera) {
lightDirection.rotateByQuaternionToRef(camera.absoluteRotation, lightDirection) val distance = (controls.target - camera.position).length()
light.direction = lightDirection camera.near = distance / 100
scene.render() camera.far = max(2_000.0, 10 * distance)
camera.updateProjectionMatrix()
}
threeRenderer.render(scene, camera)
}
private fun renderLoop() {
if (rendering) {
animationFrameHandle = window.requestAnimationFrame {
try {
render()
} finally {
renderLoop()
}
}
}
} }
} }

View File

@ -2,11 +2,11 @@ package world.phantasmal.web.core.rendering.conversion
import world.phantasmal.lib.fileFormats.Vec2 import world.phantasmal.lib.fileFormats.Vec2
import world.phantasmal.lib.fileFormats.Vec3 import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.web.externals.babylon.Vector2 import world.phantasmal.web.externals.three.Vector2
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.externals.three.Vector3
fun vec2ToBabylon(v: Vec2): Vector2 = Vector2(v.x.toDouble(), v.y.toDouble()) fun vec2ToThree(v: Vec2): Vector2 = Vector2(v.x.toDouble(), v.y.toDouble())
fun vec3ToBabylon(v: Vec3): Vector3 = Vector3(v.x.toDouble(), v.y.toDouble(), v.z.toDouble()) fun vec3ToThree(v: Vec3): Vector3 = Vector3(v.x.toDouble(), v.y.toDouble(), v.z.toDouble())
fun babylonToVec3(v: Vector3): Vec3 = Vec3(v.x.toFloat(), v.y.toFloat(), v.z.toFloat()) fun threeToVec3(v: Vector3): Vec3 = Vec3(v.x.toFloat(), v.y.toFloat(), v.z.toFloat())

View File

@ -0,0 +1,186 @@
package world.phantasmal.web.core.rendering.conversion
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint16Array
import org.khronos.webgl.set
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.viewer.rendering.xvrTextureToThree
import world.phantasmal.webui.obj
class MeshBuilder {
private val positions = mutableListOf<Vector3>()
private val normals = mutableListOf<Vector3>()
private val uvs = mutableListOf<Vector2>()
/**
* One group per material.
*/
private val groups = mutableListOf<Group>()
private val textures = mutableListOf<XvrTexture>()
fun getGroupIndex(
textureId: Int?,
alpha: Boolean,
additiveBlending: Boolean,
): Int {
val idx = groups.indexOfFirst {
it.textureId == textureId &&
it.alpha == alpha &&
it.additiveBlending == additiveBlending
}
return if (idx != -1) {
idx
} else {
groups.add(Group(textureId, alpha, additiveBlending))
groups.lastIndex
}
}
val vertexCount: Int
get() = positions.size
fun getPosition(index: Int): Vector3 =
positions[index]
fun getNormal(index: Int): Vector3 =
normals[index]
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2? = null) {
positions.add(position)
normals.add(normal)
uv?.let { uvs.add(uv) }
}
fun addIndex(groupIdx: Int, index: Int) {
groups[groupIdx].indices.add(index.toShort())
}
fun addBoneWeight(groupIdx: Int, index: Int, weight: Float) {
val group = groups[groupIdx]
group.boneIndices.add(index.toShort())
group.boneWeights.add(weight)
}
fun addTextures(textures: List<XvrTexture>) {
this.textures.addAll(textures)
}
fun buildMesh(boundingVolumes: Boolean = false): Mesh =
build().let { (geom, materials) ->
if (boundingVolumes) {
geom.computeBoundingBox()
geom.computeBoundingSphere()
}
Mesh(geom, materials)
}
/**
* Creates an [InstancedMesh] with 0 instances.
*/
fun buildInstancedMesh(maxInstances: Int, boundingVolumes: Boolean = false): InstancedMesh =
build().let { (geom, materials) ->
if (boundingVolumes) {
geom.computeBoundingBox()
geom.computeBoundingSphere()
}
InstancedMesh(geom, materials, maxInstances).apply {
// Start with 0 instances.
count = 0
}
}
private fun build(): Pair<BufferGeometry, Array<Material>> {
check(this.positions.size == this.normals.size)
check(this.uvs.isEmpty() || this.positions.size == this.uvs.size)
val positions = Float32Array(3 * positions.size)
val normals = Float32Array(3 * normals.size)
val uvs = if (uvs.isEmpty()) null else Float32Array(2 * uvs.size)
for (i in this.positions.indices) {
val pos = this.positions[i]
positions[3 * i] = pos.x.toFloat()
positions[3 * i + 1] = pos.y.toFloat()
positions[3 * i + 2] = pos.z.toFloat()
val normal = this.normals[i]
normals[3 * i] = normal.x.toFloat()
normals[3 * i + 1] = normal.y.toFloat()
normals[3 * i + 2] = normal.z.toFloat()
uvs?.let {
val uv = this.uvs[i]
uvs[2 * i] = uv.x.toFloat()
uvs[2 * i + 1] = uv.y.toFloat()
}
}
val geom = BufferGeometry()
geom.setAttribute("position", Float32BufferAttribute(positions, 3))
geom.setAttribute("normal", Float32BufferAttribute(normals, 3))
uvs?.let { geom.setAttribute("uv", Float32BufferAttribute(uvs, 2)) }
val indices = Uint16Array(groups.sumBy { it.indices.size })
var offset = 0
val texCache = mutableMapOf<Int, Texture?>()
val materials = mutableListOf<Material>()
for (group in groups) {
indices.set(group.indices.toTypedArray(), offset)
geom.addGroup(offset, group.indices.size, materials.size)
val tex = group.textureId?.let { texId ->
texCache.getOrPut(texId) {
textures.getOrNull(texId)?.let { xvm ->
xvrTextureToThree(xvm)
}
}
}
val mat = if (tex == null) {
MeshLambertMaterial(obj {
// TODO: skinning
side = DoubleSide
})
} else {
MeshBasicMaterial(obj {
map = tex
side = DoubleSide
if (group.alpha) {
transparent = true
alphaTest = 0.01
}
if (group.additiveBlending) {
transparent = true
alphaTest = 0.01
blending = AdditiveBlending
}
})
}
materials.add(mat)
offset += group.indices.size
}
geom.setIndex(Uint16BufferAttribute(indices, 1))
return Pair(geom, materials.toTypedArray())
}
private class Group(
val textureId: Int?,
val alpha: Boolean,
val additiveBlending: Boolean,
) {
val indices = mutableListOf<Short>()
val boneIndices = mutableListOf<Short>()
val boneWeights = mutableListOf<Float>()
}
}

View File

@ -1,56 +1,80 @@
package world.phantasmal.web.core.rendering.conversion package world.phantasmal.web.core.rendering.conversion
import mu.KotlinLogging import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.Vec3 import world.phantasmal.lib.fileFormats.ninja.*
import world.phantasmal.lib.fileFormats.ninja.NinjaModel import world.phantasmal.web.core.cross
import world.phantasmal.lib.fileFormats.ninja.NinjaObject import world.phantasmal.web.core.dot
import world.phantasmal.lib.fileFormats.ninja.NjModel import world.phantasmal.web.core.minus
import world.phantasmal.lib.fileFormats.ninja.XjModel import world.phantasmal.web.core.toQuaternion
import world.phantasmal.web.core.* import world.phantasmal.web.externals.three.*
import world.phantasmal.web.externals.babylon.*
import kotlin.math.cos
import kotlin.math.sin
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val DEFAULT_NORMAL = Vector3.Up() private val DEFAULT_NORMAL = Vector3(0.0, 1.0, 0.0)
private val DEFAULT_UV = Vector2.Zero() private val DEFAULT_UV = Vector2(0.0, 0.0)
private val NO_TRANSLATION = Vector3.Zero() private val NO_TRANSLATION = Vector3(0.0, 0.0, 0.0)
private val NO_ROTATION = Quaternion.Identity() private val NO_ROTATION = Quaternion()
private val NO_SCALE = Vector3.One() private val NO_SCALE = Vector3(1.0, 1.0, 1.0)
fun ninjaObjectToVertexData(ninjaObject: NinjaObject<*>): VertexData = fun ninjaObjectToMesh(
NinjaToVertexDataConverter(VertexDataBuilder()).convert(ninjaObject)
fun ninjaObjectToVertexDataBuilder(
ninjaObject: NinjaObject<*>, ninjaObject: NinjaObject<*>,
builder: VertexDataBuilder, textures: List<XvrTexture>,
): VertexData = boundingVolumes: Boolean = false
NinjaToVertexDataConverter(builder).convert(ninjaObject) ): Mesh {
val builder = MeshBuilder()
builder.addTextures(textures)
NinjaToMeshConverter(builder).convert(ninjaObject)
return builder.buildMesh(boundingVolumes)
}
fun ninjaObjectToInstancedMesh(
ninjaObject: NinjaObject<*>,
textures: List<XvrTexture>,
maxInstances: Int,
boundingVolumes: Boolean = false,
): InstancedMesh {
val builder = MeshBuilder()
builder.addTextures(textures)
NinjaToMeshConverter(builder).convert(ninjaObject)
return builder.buildInstancedMesh(maxInstances, boundingVolumes)
}
fun ninjaObjectToMeshBuilder(
ninjaObject: NinjaObject<*>,
builder: MeshBuilder,
) {
NinjaToMeshConverter(builder).convert(ninjaObject)
}
// TODO: take into account different kinds of meshes/vertices (with or without normals, uv, etc.). // TODO: take into account different kinds of meshes/vertices (with or without normals, uv, etc.).
private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder) { private class NinjaToMeshConverter(private val builder: MeshBuilder) {
private val vertexHolder = VertexHolder() private val vertexHolder = VertexHolder()
private var boneIndex = 0 private var boneIndex = 0
fun convert(ninjaObject: NinjaObject<*>): VertexData { fun convert(ninjaObject: NinjaObject<*>) {
objectToVertexData(ninjaObject, Matrix.Identity()) convertObject(ninjaObject, Matrix4())
return builder.build()
} }
private fun objectToVertexData(obj: NinjaObject<*>, parentMatrix: Matrix) { private fun convertObject(obj: NinjaObject<*>, parentMatrix: Matrix4) {
val ef = obj.evaluationFlags val ef = obj.evaluationFlags
val matrix = Matrix.Compose( val euler = Euler(
if (ef.noScale) NO_SCALE else vec3ToBabylon(obj.scale), obj.rotation.x.toDouble(),
if (ef.noRotate) NO_ROTATION else eulerToQuat(obj.rotation, ef.zxyRotationOrder), obj.rotation.y.toDouble(),
if (ef.noTranslate) NO_TRANSLATION else vec3ToBabylon(obj.position), obj.rotation.z.toDouble(),
if (ef.zxyRotationOrder) "ZXY" else "ZYX",
) )
matrix.preMultiply(parentMatrix) val matrix = Matrix4()
.compose(
if (ef.noTranslate) NO_TRANSLATION else vec3ToThree(obj.position),
if (ef.noRotate) NO_ROTATION else euler.toQuaternion(),
if (ef.noScale) NO_SCALE else vec3ToThree(obj.scale),
)
.premultiply(parentMatrix)
if (!ef.hidden) { if (!ef.hidden) {
obj.model?.let { model -> obj.model?.let { model ->
modelToVertexData(model, matrix) convertModel(model, matrix)
} }
} }
@ -58,28 +82,27 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
if (!ef.breakChildTrace) { if (!ef.breakChildTrace) {
obj.children.forEach { child -> obj.children.forEach { child ->
objectToVertexData(child, matrix) convertObject(child, matrix)
} }
} }
} }
private fun modelToVertexData(model: NinjaModel, matrix: Matrix) = private fun convertModel(model: NinjaModel, matrix: Matrix4) =
when (model) { when (model) {
is NjModel -> njModelToVertexData(model, matrix) is NjModel -> convertNjModel(model, matrix)
is XjModel -> xjModelToVertexData(model, matrix) is XjModel -> convertXjModel(model, matrix)
} }
private fun njModelToVertexData(model: NjModel, matrix: Matrix) { private fun convertNjModel(model: NjModel, matrix: Matrix4) {
val normalMatrix = Matrix.Identity() val normalMatrix = Matrix3().getNormalMatrix(matrix)
matrix.toNormalMatrix(normalMatrix)
val newVertices = model.vertices.map { vertex -> val newVertices = model.vertices.map { vertex ->
vertex?.let { vertex?.let {
val position = vec3ToBabylon(vertex.position) val position = vec3ToThree(vertex.position)
val normal = vertex.normal?.let(::vec3ToBabylon) ?: Vector3.Up() val normal = vertex.normal?.let(::vec3ToThree) ?: Vector3(0.0, 1.0, 0.0)
matrix.multiply(position) position.applyMatrix4(matrix)
normalMatrix.multiply3x3(normal) normal.applyMatrix3(normalMatrix)
Vertex( Vertex(
boneIndex, boneIndex,
@ -95,7 +118,11 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
vertexHolder.add(newVertices) vertexHolder.add(newVertices)
for (mesh in model.meshes) { for (mesh in model.meshes) {
val startIndexCount = builder.indexCount val group = builder.getGroupIndex(
mesh.textureId,
alpha = mesh.useAlpha,
additiveBlending = mesh.srcAlpha != 4 || mesh.dstAlpha != 5
)
var i = 0 var i = 0
for (meshVertex in mesh.vertices) { for (meshVertex in mesh.vertices) {
@ -108,24 +135,24 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
} else { } else {
val vertex = vertices.last() val vertex = vertices.last()
val normal = val normal =
vertex.normal ?: meshVertex.normal?.let(::vec3ToBabylon) ?: DEFAULT_NORMAL vertex.normal ?: meshVertex.normal?.let(::vec3ToThree) ?: DEFAULT_NORMAL
val index = builder.vertexCount val index = builder.vertexCount
builder.addVertex( builder.addVertex(
vertex.position, vertex.position,
normal, normal,
meshVertex.texCoords?.let(::vec2ToBabylon) ?: DEFAULT_UV meshVertex.texCoords?.let(::vec2ToThree) ?: DEFAULT_UV
) )
if (i >= 2) { if (i >= 2) {
if (i % 2 == if (mesh.clockwiseWinding) 0 else 1) { if (i % 2 == if (mesh.clockwiseWinding) 1 else 0) {
builder.addIndex(index - 2) builder.addIndex(group, index - 2)
builder.addIndex(index - 1) builder.addIndex(group, index - 1)
builder.addIndex(index) builder.addIndex(group, index)
} else { } else {
builder.addIndex(index - 2) builder.addIndex(group, index - 2)
builder.addIndex(index) builder.addIndex(group, index)
builder.addIndex(index - 1) builder.addIndex(group, index - 1)
} }
} }
@ -141,6 +168,7 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
for (j in boneIndices.indices) { for (j in boneIndices.indices) {
builder.addBoneWeight( builder.addBoneWeight(
group,
boneIndices[j], boneIndices[j],
if (totalWeight > 0f) boneWeights[j] / totalWeight else 0f if (totalWeight > 0f) boneWeights[j] / totalWeight else 0f
) )
@ -149,38 +177,41 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
i++ i++
} }
} }
// TODO: support multiple materials
// builder.addGroup(
// startIndexCount
// )
} }
} }
private fun xjModelToVertexData(model: XjModel, matrix: Matrix) { private fun convertXjModel(model: XjModel, matrix: Matrix4) {
val indexOffset = builder.vertexCount val indexOffset = builder.vertexCount
val normalMatrix = Matrix.Identity() val normalMatrix = Matrix3().getNormalMatrix(matrix)
matrix.toNormalMatrix(normalMatrix)
for (vertex in model.vertices) { for (vertex in model.vertices) {
val p = vec3ToBabylon(vertex.position) val p = vec3ToThree(vertex.position)
matrix.multiply(p) p.applyMatrix4(matrix)
val n = vertex.normal?.let(::vec3ToBabylon) ?: Vector3.Up() val n = vertex.normal?.let(::vec3ToThree) ?: Vector3(0.0, 1.0, 0.0)
normalMatrix.multiply3x3(n) n.applyMatrix3(normalMatrix)
val uv = vertex.uv?.let(::vec2ToBabylon) ?: DEFAULT_UV val uv = vertex.uv?.let(::vec2ToThree) ?: DEFAULT_UV
builder.addVertex(p, n, uv) builder.addVertex(p, n, uv)
} }
var currentMatIdx: Int? = null var currentTextureIdx: Int? = null
var currentSrcAlpha: Int? = null var currentSrcAlpha: Int? = null
var currentDstAlpha: Int? = null var currentDstAlpha: Int? = null
for (mesh in model.meshes) { for (mesh in model.meshes) {
val startIndexCount = builder.indexCount mesh.material.textureId?.let { currentTextureIdx = it }
var clockwise = true mesh.material.srcAlpha?.let { currentSrcAlpha = it }
mesh.material.dstAlpha?.let { currentDstAlpha = it }
val group = builder.getGroupIndex(
currentTextureIdx,
alpha = true,
additiveBlending = currentSrcAlpha != 4 || currentDstAlpha != 5,
)
var clockwise = false
for (j in 2 until mesh.indices.size) { for (j in 2 until mesh.indices.size) {
val a = indexOffset + mesh.indices[j - 2] val a = indexOffset + mesh.indices[j - 2]
@ -198,8 +229,8 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
// most models. // most models.
val normal = (pb - pa) cross (pc - pa) val normal = (pb - pa) cross (pc - pa)
if (!clockwise) { if (clockwise) {
normal.negateInPlace() normal.negate()
} }
val oppositeCount = val oppositeCount =
@ -212,30 +243,17 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
} }
if (clockwise) { if (clockwise) {
builder.addIndex(b) builder.addIndex(group, b)
builder.addIndex(a) builder.addIndex(group, a)
builder.addIndex(c) builder.addIndex(group, c)
} else { } else {
builder.addIndex(a) builder.addIndex(group, a)
builder.addIndex(b) builder.addIndex(group, b)
builder.addIndex(c) builder.addIndex(group, c)
} }
clockwise = !clockwise clockwise = !clockwise
} }
mesh.material.textureId?.let { currentMatIdx = it }
mesh.material.srcAlpha?.let { currentSrcAlpha = it }
mesh.material.dstAlpha?.let { currentDstAlpha = it }
// TODO: support multiple materials
// builder.addGroup(
// start_index_count,
// this.builder.index_count - start_index_count,
// current_mat_idx,
// true,
// current_src_alpha !== 4 || current_dst_alpha !== 5,
// );
} }
} }
} }
@ -266,33 +284,3 @@ private class VertexHolder {
fun get(index: Int): List<Vertex> = buffer[index] fun get(index: Int): List<Vertex> = buffer[index]
} }
private fun eulerToQuat(angles: Vec3, zxyRotationOrder: Boolean): Quaternion {
val x = angles.x.toDouble()
val y = angles.y.toDouble()
val z = angles.z.toDouble()
val c1 = cos(x / 2)
val c2 = cos(y / 2)
val c3 = cos(z / 2)
val s1 = sin(x / 2)
val s2 = sin(y / 2)
val s3 = sin(z / 2)
return if (zxyRotationOrder) {
Quaternion(
s1 * c2 * c3 - c1 * s2 * s3,
c1 * s2 * c3 + s1 * c2 * s3,
c1 * c2 * s3 + s1 * s2 * c3,
c1 * c2 * c3 - s1 * s2 * s3,
)
} else {
Quaternion(
s1 * c2 * c3 - c1 * s2 * s3,
c1 * s2 * c3 + s1 * c2 * s3,
c1 * c2 * s3 - s1 * s2 * c3,
c1 * c2 * c3 + s1 * s2 * s3,
)
}
}

View File

@ -1,89 +0,0 @@
package world.phantasmal.web.core.rendering.conversion
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint16Array
import org.khronos.webgl.set
import world.phantasmal.web.externals.babylon.Vector2
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.externals.babylon.VertexData
class VertexDataBuilder {
private val positions = mutableListOf<Vector3>()
private val normals = mutableListOf<Vector3>()
private val uvs = mutableListOf<Vector2>()
private val indices = mutableListOf<Short>()
private val boneIndices = mutableListOf<Short>()
private val boneWeights = mutableListOf<Float>()
val vertexCount: Int
get() = positions.size
val indexCount: Int
get() = indices.size
fun getPosition(index: Int): Vector3 =
positions[index]
fun getNormal(index: Int): Vector3 =
normals[index]
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2? = null) {
positions.add(position)
normals.add(normal)
uv?.let { uvs.add(uv) }
}
fun addIndex(index: Int) {
indices.add(index.toShort())
}
fun addBoneWeight(index: Int, weight: Float) {
boneIndices.add(index.toShort())
boneWeights.add(weight)
}
// TODO: support multiple materials
// fun addGroup(
// offset: Int,
// size: Int,
// textureId: Int?,
// alpha: Boolean = false,
// additiveBlending: Boolean = false,
// ) {
//
// }
fun build(): VertexData {
check(this.positions.size == this.normals.size)
check(this.uvs.isEmpty() || this.positions.size == this.uvs.size)
val positions = Float32Array(3 * positions.size)
val normals = Float32Array(3 * normals.size)
val uvs = if (uvs.isEmpty()) null else Float32Array(2 * uvs.size)
for (i in this.positions.indices) {
val pos = this.positions[i]
positions[3 * i] = pos.x.toFloat()
positions[3 * i + 1] = pos.y.toFloat()
positions[3 * i + 2] = pos.z.toFloat()
val normal = this.normals[i]
normals[3 * i] = normal.x.toFloat()
normals[3 * i + 1] = normal.y.toFloat()
normals[3 * i + 2] = normal.z.toFloat()
uvs?.let {
val uv = this.uvs[i]
uvs[2 * i] = uv.x.toFloat()
uvs[2 * i + 1] = uv.y.toFloat()
}
}
val data = VertexData()
data.positions = positions
data.normals = normals
data.uvs = uvs
data.indices = Uint16Array(indices.toTypedArray())
return data
}
}

View File

@ -1,19 +1,15 @@
package world.phantasmal.web.core.widgets package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.rendering.Renderer import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
import kotlin.math.floor
class RendererWidget( class RendererWidget(
scope: CoroutineScope, scope: CoroutineScope,
private val canvas: HTMLCanvasElement,
private val renderer: Renderer, private val renderer: Renderer,
) : Widget(scope) { ) : Widget(scope) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-core-renderer" className = "pw-core-renderer"
@ -28,11 +24,10 @@ class RendererWidget(
} }
addDisposable(size.observe { (size) -> addDisposable(size.observe { (size) ->
canvas.width = floor(size.width).toInt() renderer.setSize(size.width, size.height)
canvas.height = floor(size.height).toInt()
}) })
append(canvas) append(renderer.canvas)
} }
companion object { companion object {

View File

@ -1,506 +0,0 @@
@file:JsModule("@babylonjs/core")
@file:JsNonModule
@file:Suppress("FunctionName", "unused", "CovariantEquals")
package world.phantasmal.web.externals.babylon
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint16Array
import org.w3c.dom.HTMLCanvasElement
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
fun negate(): Vector2
fun negateInPlace(): Vector2
fun clone(): Vector2
fun copyFrom(source: Vector2): Vector2
fun equals(otherVector: Vector2): Boolean
companion object {
fun Zero(): Vector2
fun Dot(left: Vector2, right: Vector2): Double
}
}
external class Vector3(x: Double, y: Double, z: Double) {
var x: Double
var y: Double
var z: Double
fun set(x: Double, y: Double, z: Double): Vector2
fun toQuaternion(): Quaternion
fun add(otherVector: Vector3): Vector3
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
/**
* Returns a new Vector3 set with the current Vector3 coordinates multiplied by the float "scale"
*/
fun scale(scale: Double): Vector3
/**
* Multiplies the Vector3 coordinates by the float "scale"
*
* @return the current updated Vector3
*/
fun scaleInPlace(scale: Double): Vector3
fun rotateByQuaternionToRef(quaternion: Quaternion, result: Vector3): Vector3
fun clone(): Vector3
fun copyFrom(source: Vector3): Vector3
fun equals(otherVector: Vector3): Boolean
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
fun TransformCoordinatesToRef(vector: Vector3, transformation: Matrix, result: Vector3)
fun TransformNormal(vector: Vector3, transformation: Matrix): Vector3
fun TransformNormalToRef(vector: Vector3, transformation: Matrix, result: Vector3)
}
}
external class Quaternion(
x: Double = definedExternally,
y: Double = definedExternally,
z: Double = definedExternally,
w: Double = definedExternally,
) {
/**
* Multiplies two quaternions
* @return a new quaternion set as the multiplication result of the current one with the given one "q1"
*/
fun multiply(q1: Quaternion): Quaternion
/**
* Updates the current quaternion with the multiplication of itself with the given one "q1"
* @return the current, updated quaternion
*/
fun multiplyInPlace(q1: Quaternion): Quaternion
/**
* Sets the given "result" as the the multiplication result of the current one with the given one "q1"
* @return the current quaternion
*/
fun multiplyToRef(q1: Quaternion, result: Quaternion): Quaternion
fun toEulerAngles(): Vector3
fun toEulerAnglesToRef(result: Vector3): Quaternion
fun rotateByQuaternionToRef(quaternion: Quaternion, result: Vector3): Vector3
fun clone(): Quaternion
fun copyFrom(other: Quaternion): Quaternion
companion object {
fun Identity(): Quaternion
fun FromEulerAngles(x: Double, y: Double, z: Double): Quaternion
fun FromEulerAnglesToRef(x: Double, y: Double, z: Double, result: Quaternion): Quaternion
fun RotationYawPitchRoll(yaw: Double, pitch: Double, roll: Double): Quaternion
fun Inverse(q: Quaternion): Quaternion
fun InverseToRef(q: Quaternion, result: Quaternion): Quaternion
}
}
external class Matrix {
fun multiply(other: Matrix): Matrix
fun multiplyToRef(other: Matrix, result: Matrix): Matrix
fun toNormalMatrix(ref: Matrix)
fun copyFrom(other: Matrix): Matrix
fun equals(value: Matrix): Boolean
companion object {
val IdentityReadOnly: Matrix
fun Identity(): Matrix
fun Compose(scale: Vector3, rotation: Quaternion, translation: Vector3): Matrix
}
}
external class EventState
external class Observable<T> {
fun add(
callback: (eventData: T, eventState: EventState) -> Unit,
mask: Int = definedExternally,
insertFirst: Boolean = definedExternally,
scope: Any = definedExternally,
unregisterOnFirstCall: Boolean = definedExternally,
): Observer<T>?
fun remove(observer: Observer<T>?): Boolean
fun removeCallback(
callback: (eventData: T, eventState: EventState) -> Unit,
scope: Any = definedExternally,
): Boolean
}
external class Observer<T>
open external class ThinEngine {
val description: String
/**
* Register and execute a render loop. The engine can have more than one render function
* @param renderFunction defines the function to continuously execute
*/
fun runRenderLoop(renderFunction: () -> Unit)
/**
* stop executing a render loop function and remove it from the execution array
* @param renderFunction defines the function to be removed. If not provided all functions will
* be removed.
*/
fun stopRenderLoop(renderFunction: () -> Unit = definedExternally)
fun getRenderWidth(useScreen: Boolean = definedExternally): Double
fun getRenderHeight(useScreen: Boolean = definedExternally): Double
fun dispose()
}
open external class Engine(
canvasOrContext: HTMLCanvasElement?,
antialias: Boolean = definedExternally,
) : ThinEngine
external class NullEngine : Engine
external class Ray(origin: Vector3, direction: Vector3, length: Double = definedExternally) {
var origin: Vector3
var direction: Vector3
var length: Double
fun intersectsPlane(plane: Plane): Double?
companion object {
fun Zero(): 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?
fun getNormal(
useWorldCoordinates: Boolean = definedExternally,
useVerticesNormals: Boolean = definedExternally,
): Vector3?
fun getTextureCoordinates(): Vector2?
}
external class Scene(engine: Engine) {
var useRightHandedSystem: Boolean
var clearColor: Color4
var pointerX: Double
var pointerY: Double
fun render()
fun addLight(light: Light)
fun addMesh(newMesh: AbstractMesh, recursive: Boolean? = definedExternally)
fun addTransformNode(newTransformNode: TransformNode)
fun removeLight(toRemove: Light)
fun removeMesh(toRemove: TransformNode, recursive: Boolean? = definedExternally)
fun removeTransformNode(toRemove: TransformNode)
fun createPickingRay(
x: Double,
y: Double,
world: Matrix,
camera: Camera?,
cameraViewSpace: Boolean = definedExternally,
): Ray
fun createPickingRayToRef(
x: Double,
y: Double,
world: Matrix,
result: Ray,
camera: Camera?,
cameraViewSpace: Boolean = definedExternally,
): Scene
fun createPickingRayInCameraSpaceToRef(
x: Double,
y: Double,
result: Ray,
camera: Camera = definedExternally,
): Scene
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 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()
}
open external class Node {
var metadata: Any?
var parent: Node?
fun isEnabled(checkAncestors: Boolean = definedExternally): Boolean
fun setEnabled(value: Boolean)
fun getWorldMatrix(): Matrix
/**
* Releases resources associated with this node.
* @param doNotRecurse Set to true to not recurse into each children (recurse into each children by default)
* @param disposeMaterialAndTextures Set to true to also dispose referenced materials and textures (false by default)
*/
fun dispose(
doNotRecurse: Boolean = definedExternally,
disposeMaterialAndTextures: Boolean = definedExternally,
)
}
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 getViewMatrix(force: Boolean = definedExternally): Matrix
fun getProjectionMatrix(force: Boolean = definedExternally): Matrix
fun getTransformationMatrix(): Matrix
fun attachControl(noPreventDefault: Boolean = definedExternally)
fun detachControl()
fun storeState(): Camera
fun restoreState(): Boolean
}
open external class TargetCamera : Camera {
var target: Vector3
}
/**
* @param setActiveOnSceneIfNoneActive default true
*/
external class ArcRotateCamera(
name: String,
alpha: Double,
beta: Double,
radius: Double,
target: Vector3,
scene: Scene,
setActiveOnSceneIfNoneActive: Boolean = definedExternally,
) : TargetCamera {
var alpha: Double
var beta: Double
var radius: Double
var inertia: Double
var angularSensibilityX: Double
var angularSensibilityY: Double
var panningInertia: Double
var panningSensibility: Double
var panningAxis: Vector3
var pinchDeltaPercentage: Double
var wheelDeltaPercentage: Double
var lowerBetaLimit: Double
val inputs: ArcRotateCameraInputsManager
fun attachControl(
element: HTMLCanvasElement,
noPreventDefault: Boolean,
useCtrlForPanning: Boolean,
panningMouseButton: Int,
)
}
open external class CameraInputsManager<TCamera : Camera> {
fun attachElement(noPreventDefault: Boolean = definedExternally)
fun detachElement(disconnect: Boolean = definedExternally)
}
external class ArcRotateCameraInputsManager : CameraInputsManager<ArcRotateCamera>
abstract external class Light : Node
external class HemisphericLight(name: String, direction: Vector3, scene: Scene) : Light {
var direction: Vector3
}
open external class TransformNode(
name: String,
scene: Scene? = definedExternally,
isPure: Boolean = definedExternally,
) : Node {
var position: Vector3
var rotation: Vector3
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
}
external class Mesh(
name: String,
scene: Scene? = definedExternally,
parent: Node? = definedExternally,
source: Mesh? = definedExternally,
doNotCloneChildren: Boolean = definedExternally,
clonePhysicsImpostor: Boolean = definedExternally,
) : AbstractMesh {
fun createInstance(name: String): InstancedMesh
fun bakeCurrentTransformIntoVertices(
bakeIndependenlyOfChildren: Boolean = definedExternally,
): Mesh
}
external class InstancedMesh : AbstractMesh
external class BoundingInfo {
val boundingBox: BoundingBox
val boundingSphere: BoundingSphere
}
external class BoundingBox {
val center: Vector3
val centerWorld: Vector3
val directions: Array<Vector3>
val extendSize: Vector3
val extendSizeWorld: Vector3
val maximum: Vector3
val maximumWorld: Vector3
val minimum: Vector3
val minimumWorld: Vector3
val vectors: Array<Vector3>
val vectorsWorld: Array<Vector3>
}
external class BoundingSphere {
val center: Vector3
val centerWorld: Vector3
val maximum: Vector3
val minimum: Vector3
val radius: Double
val radiusWorld: Double
}
external class MeshBuilder {
companion object {
interface CreateCylinderOptions {
var height: Double
var diameterTop: Double
var diameterBottom: Double
var diameter: Double
var tessellation: Double
var subdivisions: Double
var arc: Double
}
fun CreateCylinder(
name: String,
options: CreateCylinderOptions,
scene: Scene? = definedExternally,
): Mesh
}
}
external class Plane(a: Double, b: Double, c: Double, d: Double) {
var normal: Vector3
var d: Double
companion object {
/**
* Note : the vector "normal" is updated because normalized.
*/
fun FromPositionAndNormal(origin: Vector3, normal: Vector3): Plane
}
}
external class VertexData {
var positions: Float32Array? // number[] | Float32Array
var normals: Float32Array? // number[] | Float32Array
var uvs: Float32Array? // number[] | Float32Array
var indices: Uint16Array? // number[] | Int32Array | Uint32Array | Uint16Array
fun applyToMesh(mesh: Mesh, updatable: Boolean = definedExternally): VertexData
}
external class Color3(
r: Double = definedExternally,
g: Double = definedExternally,
b: Double = definedExternally,
) {
var r: Double
var g: Double
var b: Double
}
external class Color4(
r: Double = definedExternally,
g: Double = definedExternally,
b: Double = definedExternally,
a: Double = definedExternally,
) {
var r: Double
var g: Double
var b: Double
var a: Double
companion object {
/**
* Creates a new Color4 from integer values (< 256)
*/
fun FromInts(r: Int, g: Int, b: Int, a: Int): Color4
}
}

View File

@ -0,0 +1,23 @@
@file:JsModule("three/examples/jsm/controls/OrbitControls")
@file:JsNonModule
@file:Suppress("PropertyName")
package world.phantasmal.web.externals.three
import org.w3c.dom.HTMLElement
external interface OrbitControlsMouseButtons {
var LEFT: MOUSE
var MIDDLE: MOUSE
var RIGHT: MOUSE
}
external class OrbitControls(`object`: Camera, domElement: HTMLElement = definedExternally) {
var enabled: Boolean
var target: Vector3
var screenSpacePanning: Boolean
var mouseButtons: OrbitControlsMouseButtons
fun update(): Boolean
}

View File

@ -0,0 +1,608 @@
@file:JsModule("three")
@file:JsNonModule
@file:Suppress("unused", "ClassName", "CovariantEquals")
package world.phantasmal.web.externals.three
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint16Array
import org.w3c.dom.HTMLCanvasElement
external interface Vector
external class Vector2(x: Double = definedExternally, y: Double = definedExternally) : Vector {
var x: Double
var y: Double
/**
* Sets value of this vector.
*/
fun set(x: Double, y: Double): Vector2
/**
* Copies value of v to this vector.
*/
fun copy(v: Vector2): Vector2
/**
* Checks for strict equality of this vector and v.
*/
fun equals(v: Vector2): Boolean
}
external class Vector3(
x: Double = definedExternally,
y: Double = definedExternally,
z: Double = definedExternally,
) : Vector {
var x: Double
var y: Double
var z: Double
/**
* Sets value of this vector.
*/
fun set(x: Double, y: Double, z: Double): Vector3
fun clone(): Vector3
/**
* Copies value of v to this vector.
*/
fun copy(v: Vector3): Vector3
/**
* Checks for strict equality of this vector and v.
*/
fun equals(v: Vector3): Boolean
/**
* Adds [v] to this vector.
*/
fun add(v: Vector3): Vector3
/**
* Subtracts [v] from this vector.
*/
fun sub(v: Vector3): Vector3
/**
* Multiplies this vector by scalar s.
*/
fun multiplyScalar(s: Double): Vector3
/**
* Inverts this vector.
*/
fun negate(): Vector3
/**
* Computes dot product of this vector and v.
*/
fun dot(v: Vector3): Double
fun length(): Double
/**
* Sets this vector to cross product of itself and [v].
*/
fun cross(v: Vector3): Vector3
fun applyEuler(euler: Euler): Vector3
fun applyMatrix3(m: Matrix3): Vector3
fun applyNormalMatrix(m: Matrix3): Vector3
fun applyMatrix4(m: Matrix4): Vector3
fun applyQuaternion(q: Quaternion): Vector3
}
external class Quaternion(
x: Double = definedExternally,
y: Double = definedExternally,
z: Double = definedExternally,
w: Double = definedExternally,
) {
fun setFromEuler(euler: Euler): Quaternion
/**
* Inverts this quaternion.
*/
fun inverse(): Quaternion
/**
* Multiplies this quaternion by [q].
*/
fun multiply(q: Quaternion): Quaternion
}
external class Euler(
x: Double = definedExternally,
y: Double = definedExternally,
z: Double = definedExternally,
order: String = definedExternally,
) {
var x: Double
var y: Double
var z: Double
fun set(x: Double, y: Double, z: Double, order: String = definedExternally): Euler
fun copy(euler: Euler): Euler
fun setFromQuaternion(q: Quaternion, order: String = definedExternally): Euler
}
external class Matrix3 {
fun getNormalMatrix(matrix4: Matrix4): Matrix3
}
external class Matrix4 {
fun compose(translation: Vector3, rotation: Quaternion, scale: Vector3): Matrix4
fun premultiply(m: Matrix4): Matrix4
}
open external class EventDispatcher
external interface Renderer {
val domElement: HTMLCanvasElement
fun render(scene: Object3D, camera: Camera)
fun setSize(width: Double, height: Double, updateStyle: Boolean = definedExternally)
}
external interface WebGLRendererParameters {
var alpha: Boolean
var premultipliedAlpha: Boolean
var antialias: Boolean
}
external class WebGLRenderer(parameters: WebGLRendererParameters = definedExternally) : Renderer {
override val domElement: HTMLCanvasElement
override fun render(scene: Object3D, camera: Camera)
override fun setSize(width: Double, height: Double, updateStyle: Boolean)
fun setPixelRatio(value: Double)
fun dispose()
}
open external class Object3D {
/**
* Optional name of the object (doesn't need to be unique).
*/
var name: String
var parent: Object3D?
var children: Array<Object3D>
/**
* Object's local position.
*/
val position: Vector3
/**
* Object's local rotation (Euler angles), in radians.
*/
val rotation: Euler
/**
* Global rotation.
*/
val quaternion: Quaternion
/**
* Object's local scale.
*/
val scale: Vector3
/**
* Local transform.
*/
var matrix: Matrix4
/**
* An object that can be used to store custom data about the Object3d. It should not hold references to functions as these will not be cloned.
*/
var userData: Any
fun add(vararg `object`: Object3D): Object3D
fun remove(vararg `object`: Object3D): Object3D
fun clear(): Object3D
/**
* Updates local transform.
*/
fun updateMatrix()
/**
* Updates global transform of the object and its children.
*/
fun updateMatrixWorld(force: Boolean = definedExternally)
fun clone(recursive: Boolean = definedExternally): Object3D
}
external class Group : Object3D
open external class Mesh(
geometry: Geometry = definedExternally,
material: Material = definedExternally,
) : Object3D {
constructor(
geometry: Geometry,
material: Array<Material>,
)
constructor(
geometry: BufferGeometry = definedExternally,
material: Material = definedExternally,
)
constructor(
geometry: BufferGeometry,
material: Array<Material>,
)
var geometry: Any /* Geometry | BufferGeometry */
var material: Any /* Material | Material[] */
fun translateY(distance: Double): Mesh
}
external class InstancedMesh(
geometry: Geometry,
material: Material,
count: Int,
) : Mesh {
constructor(
geometry: Geometry,
material: Array<Material>,
count: Int,
)
constructor(
geometry: BufferGeometry,
material: Material,
count: Int,
)
constructor(
geometry: BufferGeometry,
material: Array<Material>,
count: Int,
)
var count: Int
var instanceMatrix: BufferAttribute
fun getMatrixAt(index: Int, matrix: Matrix4)
fun setMatrixAt(index: Int, matrix: Matrix4)
}
external class Scene : Object3D {
var background: dynamic /* null | Color | Texture | WebGLCubeRenderTarget */
}
open external class Camera : Object3D
external class PerspectiveCamera(
fov: Double = definedExternally,
aspect: Double = definedExternally,
near: Double = definedExternally,
far: Double = definedExternally,
) : Camera {
var fov: Double
var aspect: Double
var near: Double
var far: Double
/**
* Updates the camera projection matrix. Must be called after change of parameters.
*/
fun updateProjectionMatrix()
}
external class OrthographicCamera(
left: Double,
right: Double,
top: Double,
bottom: Double,
near: Double = definedExternally,
far: Double = definedExternally,
) : Camera {
/**
* Camera frustum left plane.
*/
var left: Double
/**
* Camera frustum right plane.
*/
var right: Double
/**
* Camera frustum top plane.
*/
var top: Double
/**
* Camera frustum bottom plane.
*/
var bottom: Double
/**
* Camera frustum near plane.
*/
var near: Double
/**
* Camera frustum far plane.
*/
var far: Double
/**
* Updates the camera projection matrix. Must be called after change of parameters.
*/
fun updateProjectionMatrix()
}
open external class Light : Object3D
external class HemisphereLight(
skyColor: Color = definedExternally,
groundColor: Color = definedExternally,
intensity: Double = definedExternally,
) : Light {
constructor(
skyColor: Int = definedExternally,
groundColor: Int = definedExternally,
intensity: Double = definedExternally,
)
constructor(
skyColor: String = definedExternally,
groundColor: String = definedExternally,
intensity: Double = definedExternally,
)
}
external class Color(r: Double, g: Double, b: Double) {
constructor(color: Color)
constructor(color: String)
constructor(color: Int)
}
open external class Geometry : EventDispatcher {
/**
* Array of face UV layers.
* Each UV layer is an array of UV matching order and number of vertices in faces.
* To signal an update in this array, Geometry.uvsNeedUpdate needs to be set to true.
*/
var faceVertexUvs: Array<Array<Array<Vector2>>>
fun translate(x: Double, y: Double, z: Double): Geometry
fun dispose()
}
external class PlaneGeometry(
width: Double = definedExternally,
height: Double = definedExternally,
widthSegments: Double = definedExternally,
heightSegments: Double = definedExternally,
) : Geometry
open external class BufferGeometry : EventDispatcher {
var boundingBox: Box3?
fun setIndex(index: BufferAttribute?)
fun setIndex(index: Array<Double>?)
fun setAttribute(name: String, attribute: BufferAttribute): BufferGeometry
fun setAttribute(name: String, attribute: InterleavedBufferAttribute): BufferGeometry
fun addGroup(start: Int, count: Int, materialIndex: Int = definedExternally)
fun translate(x: Double, y: Double, z: Double): BufferGeometry
fun computeBoundingBox()
fun computeBoundingSphere()
fun dispose()
}
external class CylinderBufferGeometry(
radiusTop: Double = definedExternally,
radiusBottom: Double = definedExternally,
height: Double = definedExternally,
radialSegments: Int = definedExternally,
heightSegments: Int = definedExternally,
openEnded: Boolean = definedExternally,
thetaStart: Double = definedExternally,
thetaLength: Double = definedExternally,
) : BufferGeometry
open external class BufferAttribute {
var needsUpdate: Boolean
fun copyAt(index1: Int, attribute: BufferAttribute, index2: Int): BufferAttribute
}
external class Uint16BufferAttribute(
array: Uint16Array,
itemSize: Int,
normalize: Boolean = definedExternally,
) : BufferAttribute
external class Float32BufferAttribute(
array: Float32Array,
itemSize: Int,
normalize: Boolean = definedExternally,
) : BufferAttribute
external class InterleavedBufferAttribute
external interface Side
external object FrontSide : Side
external object BackSide : Side
external object DoubleSide : Side
external interface Blending
external object NoBlending : Blending
external object NormalBlending : Blending
external object AdditiveBlending : Blending
external object SubtractiveBlending : Blending
external object MultiplyBlending : Blending
external object CustomBlending : Blending
external interface MaterialParameters {
var alphaTest: Double
var blending: Blending
var side: Side
var transparent: Boolean
}
open external class Material : EventDispatcher {
/**
* This disposes the material. Textures of a material don't get disposed. These needs to be disposed by [Texture].
*/
fun dispose()
}
external interface MeshBasicMaterialParameters : MaterialParameters {
var color: Color
var map: Texture?
var skinning: Boolean
}
external class MeshBasicMaterial(
parameters: MeshBasicMaterialParameters = definedExternally,
) : Material {
var map: Texture?
}
external interface MeshLambertMaterialParameters : MaterialParameters {
var skinning: Boolean
}
external class MeshLambertMaterial(
parameters: MeshLambertMaterialParameters = definedExternally,
) : Material
open external class Texture : EventDispatcher {
var needsUpdate: Boolean
fun dispose()
}
external interface Mapping
external object UVMapping : Mapping
external object CubeReflectionMapping : Mapping
external object CubeRefractionMapping : Mapping
external object EquirectangularReflectionMapping : Mapping
external object EquirectangularRefractionMapping : Mapping
external object CubeUVReflectionMapping : Mapping
external object CubeUVRefractionMapping : Mapping
external interface Wrapping
external object RepeatWrapping : Wrapping
external object ClampToEdgeWrapping : Wrapping
external object MirroredRepeatWrapping : Wrapping
external interface TextureFilter
external object NearestFilter : TextureFilter
external object NearestMipmapNearestFilter : TextureFilter
external object NearestMipMapNearestFilter : TextureFilter
external object NearestMipmapLinearFilter : TextureFilter
external object NearestMipMapLinearFilter : TextureFilter
external object LinearFilter : TextureFilter
external object LinearMipmapNearestFilter : TextureFilter
external object LinearMipMapNearestFilter : TextureFilter
external object LinearMipmapLinearFilter : TextureFilter
external object LinearMipMapLinearFilter : TextureFilter
external interface TextureDataType
external object UnsignedByteType : TextureDataType
external object ByteType : TextureDataType
external object ShortType : TextureDataType
external object UnsignedShortType : TextureDataType
external object IntType : TextureDataType
external object UnsignedIntType : TextureDataType
external object FloatType : TextureDataType
external object HalfFloatType : TextureDataType
external object UnsignedShort4444Type : TextureDataType
external object UnsignedShort5551Type : TextureDataType
external object UnsignedShort565Type : TextureDataType
external object UnsignedInt248Type : TextureDataType
// DDS / ST3C Compressed texture formats
external interface CompressedPixelFormat
external object RGB_S3TC_DXT1_Format : CompressedPixelFormat
external object RGBA_S3TC_DXT1_Format : CompressedPixelFormat
external object RGBA_S3TC_DXT3_Format : CompressedPixelFormat
external object RGBA_S3TC_DXT5_Format : CompressedPixelFormat
external interface TextureEncoding
external object LinearEncoding : TextureEncoding
external object sRGBEncoding : TextureEncoding
external object GammaEncoding : TextureEncoding
external object RGBEEncoding : TextureEncoding
external object LogLuvEncoding : TextureEncoding
external object RGBM7Encoding : TextureEncoding
external object RGBM16Encoding : TextureEncoding
external object RGBDEncoding : TextureEncoding
external class CompressedTexture(
mipmaps: Array<dynamic>, /* Should have data, height and width. */
width: Int,
height: Int,
format: CompressedPixelFormat = definedExternally,
type: TextureDataType = definedExternally,
mapping: Mapping = definedExternally,
wrapS: Wrapping = definedExternally,
wrapT: Wrapping = definedExternally,
magFilter: TextureFilter = definedExternally,
minFilter: TextureFilter = definedExternally,
anisotropy: Double = definedExternally,
encoding: TextureEncoding = definedExternally,
) : Texture
external class Box3(min: Vector3 = definedExternally, max: Vector3 = definedExternally) {
var min: Vector3
var max: Vector3
}
external enum class MOUSE {
LEFT,
MIDDLE,
RIGHT,
ROTATE,
DOLLY,
PAN,
}
external class Raycaster(
origin: Vector3 = definedExternally,
direction: Vector3 = definedExternally,
near: Double = definedExternally,
far: Double = definedExternally,
) {
/**
* Updates the ray with a new origin and direction.
* @param coords 2D coordinates of the mouse, in normalized device coordinates (NDC)---X and Y components should be between -1 and 1.
* @param camera camera from which the ray should originate
*/
fun setFromCamera(coords: Vector2, camera: Camera)
}
external interface Intersection {
var distance: Double
var distanceToRay: Double?
var point: Vector3
var index: Double?
var `object`: Object3D
var uv: Vector2?
var instanceId: Int?
}

View File

@ -1,13 +1,11 @@
package world.phantasmal.web.questEditor package world.phantasmal.web.questEditor
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.questEditor.controllers.* import world.phantasmal.web.questEditor.controllers.*
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader
@ -25,20 +23,15 @@ import world.phantasmal.webui.widgets.Widget
class QuestEditor( class QuestEditor(
private val assetLoader: AssetLoader, private val assetLoader: AssetLoader,
private val uiStore: UiStore, private val uiStore: UiStore,
private val createEngine: (HTMLCanvasElement) -> Engine, private val createThreeRenderer: () -> DisposableThreeRenderer,
) : DisposableContainer(), PwTool { ) : DisposableContainer(), PwTool {
override val toolType = PwToolType.QuestEditor override val toolType = PwToolType.QuestEditor
override fun initialize(scope: CoroutineScope): Widget { override fun initialize(scope: CoroutineScope): Widget {
// Renderer
val canvas = document.createElement("CANVAS") as HTMLCanvasElement
canvas.style.outline = "none"
val renderer = addDisposable(QuestRenderer(canvas, createEngine(canvas)))
// Asset Loaders // Asset Loaders
val questLoader = addDisposable(QuestLoader(scope, assetLoader)) val questLoader = addDisposable(QuestLoader(scope, assetLoader))
val areaAssetLoader = addDisposable(AreaAssetLoader(scope, assetLoader, renderer.scene)) val areaAssetLoader = addDisposable(AreaAssetLoader(scope, assetLoader))
val entityAssetLoader = addDisposable(EntityAssetLoader(scope, assetLoader, renderer.scene)) val entityAssetLoader = addDisposable(EntityAssetLoader(scope, assetLoader))
// Stores // Stores
val areaStore = addDisposable(AreaStore(scope, areaAssetLoader)) val areaStore = addDisposable(AreaStore(scope, areaAssetLoader))
@ -57,6 +50,8 @@ class QuestEditor(
val assemblyEditorController = addDisposable(AssemblyEditorController(assemblyEditorStore)) val assemblyEditorController = addDisposable(AssemblyEditorController(assemblyEditorStore))
// Rendering // Rendering
// Renderer
val renderer = addDisposable(QuestRenderer(createThreeRenderer))
addDisposables( addDisposables(
QuestEditorMeshManager( QuestEditorMeshManager(
scope, scope,
@ -75,7 +70,7 @@ class QuestEditor(
{ s -> QuestInfoWidget(s, questInfoController) }, { s -> QuestInfoWidget(s, questInfoController) },
{ s -> NpcCountsWidget(s, npcCountsController) }, { s -> NpcCountsWidget(s, npcCountsController) },
{ s -> EntityInfoWidget(s, entityInfoController) }, { s -> EntityInfoWidget(s, entityInfoController) },
{ s -> QuestEditorRendererWidget(s, canvas, renderer) }, { s -> QuestEditorRendererWidget(s, renderer) },
{ s -> AssemblyEditorWidget(s, assemblyEditorController) }, { s -> AssemblyEditorWidget(s, assemblyEditorController) },
) )
} }

View File

@ -1,14 +1,14 @@
package world.phantasmal.web.questEditor.actions package world.phantasmal.web.questEditor.actions
import world.phantasmal.web.core.actions.Action import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
class RotateEntityAction( class RotateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit, private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val entity: QuestEntityModel<*, *>, private val entity: QuestEntityModel<*, *>,
private val newRotation: Vector3, private val newRotation: Euler,
private val oldRotation: Vector3, private val oldRotation: Euler,
private val world: Boolean, private val world: Boolean,
) : Action { ) : Action {
override val description: String = "Rotate ${entity.type.simpleName}" override val description: String = "Rotate ${entity.type.simpleName}"

View File

@ -1,7 +1,7 @@
package world.phantasmal.web.questEditor.actions package world.phantasmal.web.questEditor.actions
import world.phantasmal.web.core.actions.Action import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel import world.phantasmal.web.questEditor.models.SectionModel

View File

@ -4,7 +4,9 @@ import world.phantasmal.core.math.degToRad
import world.phantasmal.core.math.radToDeg import world.phantasmal.core.math.radToDeg
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.core.euler
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.actions.RotateEntityAction import world.phantasmal.web.questEditor.actions.RotateEntityAction
import world.phantasmal.web.questEditor.actions.TranslateEntityAction import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
@ -32,12 +34,14 @@ class EntityInfoController(private val store: QuestEditorStore) : Controller() {
val waveHidden: Val<Boolean> = store.selectedEntity.map { it !is QuestNpcModel } val waveHidden: Val<Boolean> = store.selectedEntity.map { it !is QuestNpcModel }
private val pos: Val<Vector3> = store.selectedEntity.flatMap { it?.position ?: DEFAULT_VECTOR } private val pos: Val<Vector3> =
store.selectedEntity.flatMap { it?.position ?: DEFAULT_POSITION }
val posX: Val<Double> = pos.map { it.x } val posX: Val<Double> = pos.map { it.x }
val posY: Val<Double> = pos.map { it.y } val posY: Val<Double> = pos.map { it.y }
val posZ: Val<Double> = pos.map { it.z } val posZ: Val<Double> = pos.map { it.z }
private val rot: Val<Vector3> = store.selectedEntity.flatMap { it?.rotation ?: DEFAULT_VECTOR } private val rot: Val<Euler> =
store.selectedEntity.flatMap { it?.rotation ?: DEFAULT_ROTATION }
val rotX: Val<Double> = rot.map { radToDeg(it.x) } val rotX: Val<Double> = rot.map { radToDeg(it.x) }
val rotY: Val<Double> = rot.map { radToDeg(it.y) } val rotY: Val<Double> = rot.map { radToDeg(it.y) }
val rotZ: Val<Double> = rot.map { radToDeg(it.z) } val rotZ: Val<Double> = rot.map { radToDeg(it.z) }
@ -104,13 +108,14 @@ class EntityInfoController(private val store: QuestEditorStore) : Controller() {
store.executeAction(RotateEntityAction( store.executeAction(RotateEntityAction(
setSelectedEntity = store::setSelectedEntity, setSelectedEntity = store::setSelectedEntity,
entity, entity,
Vector3(x, y, z), euler(x, y, z),
entity.rotation.value, entity.rotation.value,
false, false,
)) ))
} }
companion object { companion object {
private val DEFAULT_VECTOR = value(Vector3.Zero()) private val DEFAULT_POSITION = value(Vector3(0.0, 0.0, 0.0))
private val DEFAULT_ROTATION = value(euler(0.0, 0.0, 0.0))
} }
} }

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import world.phantasmal.lib.Endianness import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
@ -10,67 +9,71 @@ import world.phantasmal.lib.fileFormats.RenderObject
import world.phantasmal.lib.fileFormats.parseAreaCollisionGeometry import world.phantasmal.lib.fileFormats.parseAreaCollisionGeometry
import world.phantasmal.lib.fileFormats.parseAreaGeometry import world.phantasmal.lib.fileFormats.parseAreaGeometry
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.core.euler
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.conversion.VertexDataBuilder import world.phantasmal.web.core.rendering.conversion.MeshBuilder
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToVertexDataBuilder import world.phantasmal.web.core.rendering.conversion.ninjaObjectToMeshBuilder
import world.phantasmal.web.core.rendering.conversion.vec3ToBabylon import world.phantasmal.web.core.rendering.conversion.vec3ToThree
import world.phantasmal.web.externals.babylon.Mesh import world.phantasmal.web.core.rendering.disposeObject3DResources
import world.phantasmal.web.externals.babylon.Scene import world.phantasmal.web.externals.three.Group
import world.phantasmal.web.externals.babylon.TransformNode import world.phantasmal.web.externals.three.Object3D
import world.phantasmal.web.questEditor.models.AreaVariantModel import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.models.SectionModel import world.phantasmal.web.questEditor.models.SectionModel
import world.phantasmal.web.questEditor.rendering.CollisionMetadata import world.phantasmal.web.questEditor.rendering.CollisionUserData
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
/** /**
* Loads and caches area assets. * Loads and caches area assets.
*/ */
class AreaAssetLoader( class AreaAssetLoader(
private val scope: CoroutineScope, scope: CoroutineScope,
private val assetLoader: AssetLoader, private val assetLoader: AssetLoader,
private val scene: Scene,
) : DisposableContainer() { ) : DisposableContainer() {
/** /**
* This cache's values consist of a TransformNode containing area render meshes and a list of * This cache's values consist of a TransformNode containing area render meshes and a list of
* that area's sections. * that area's sections.
*/ */
private val renderObjectCache = addDisposable( private val renderObjectCache = addDisposable(
LoadingCache<CacheKey, Pair<TransformNode, List<SectionModel>>> { it.first.dispose() } LoadingCache<CacheKey, Pair<Object3D, List<SectionModel>>>(
scope,
{ (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Render)
val obj = parseAreaGeometry(buffer.cursor(Endianness.Little))
areaGeometryToTransformNodeAndSections(obj, areaVariant)
},
{ (obj3d) -> disposeObject3DResources(obj3d) },
)
) )
private val collisionObjectCache = addDisposable( private val collisionObjectCache = addDisposable(
LoadingCache<CacheKey, TransformNode> { it.dispose() } LoadingCache<CacheKey, Object3D>(
scope,
{ (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision)
val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little))
areaCollisionGeometryToTransformNode(obj, episode, areaVariant)
},
::disposeObject3DResources,
)
) )
suspend fun loadSections(episode: Episode, areaVariant: AreaVariantModel): List<SectionModel> = suspend fun loadSections(episode: Episode, areaVariant: AreaVariantModel): List<SectionModel> =
loadRenderGeometryAndSections(episode, areaVariant).second loadRenderGeometryAndSections(episode, areaVariant).second
suspend fun loadRenderGeometry(episode: Episode, areaVariant: AreaVariantModel): TransformNode = suspend fun loadRenderGeometry(episode: Episode, areaVariant: AreaVariantModel): Object3D =
loadRenderGeometryAndSections(episode, areaVariant).first loadRenderGeometryAndSections(episode, areaVariant).first
private suspend fun loadRenderGeometryAndSections( private suspend fun loadRenderGeometryAndSections(
episode: Episode, episode: Episode,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
): Pair<TransformNode, List<SectionModel>> = ): Pair<Object3D, List<SectionModel>> =
renderObjectCache.getOrPut(CacheKey(episode, areaVariant.area.id, areaVariant.id)) { renderObjectCache.get(CacheKey(episode, areaVariant))
scope.async {
val buffer = getAreaAsset(episode, areaVariant, AssetType.Render)
val obj = parseAreaGeometry(buffer.cursor(Endianness.Little))
areaGeometryToTransformNodeAndSections(scene, obj, areaVariant)
}
}.await()
suspend fun loadCollisionGeometry( suspend fun loadCollisionGeometry(
episode: Episode, episode: Episode,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
): TransformNode = ): Object3D =
collisionObjectCache.getOrPut(CacheKey(episode, areaVariant.area.id, areaVariant.id)) { collisionObjectCache.get(CacheKey(episode, areaVariant))
scope.async {
val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision)
val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little))
areaCollisionGeometryToTransformNode(scene, obj, episode, areaVariant)
}
}.await()
private suspend fun getAreaAsset( private suspend fun getAreaAsset(
episode: Episode, episode: Episode,
@ -87,8 +90,7 @@ class AreaAssetLoader(
private data class CacheKey( private data class CacheKey(
val episode: Episode, val episode: Episode,
val areaId: Int, val areaVariant: AreaVariantModel,
val areaVariantId: Int,
) )
private enum class AssetType { private enum class AssetType {
@ -96,9 +98,9 @@ class AreaAssetLoader(
} }
} }
class AreaMetadata( interface AreaUserData {
val section: SectionModel?, var sectionId: Int?
) }
private val AREA_BASE_NAMES: Map<Episode, List<Pair<String, Int>>> = mapOf( private val AREA_BASE_NAMES: Map<Episode, List<Pair<String, Int>>> = mapOf(
Episode.I to listOf( Episode.I to listOf(
@ -185,57 +187,64 @@ private fun areaVersionToBaseUrl(episode: Episode, areaVariant: AreaVariantModel
} }
private fun areaGeometryToTransformNodeAndSections( private fun areaGeometryToTransformNodeAndSections(
scene: Scene,
renderObject: RenderObject, renderObject: RenderObject,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
): Pair<TransformNode, List<SectionModel>> { ): Pair<Object3D, List<SectionModel>> {
val sections = mutableListOf<SectionModel>() val sections = mutableListOf<SectionModel>()
val node = TransformNode("Render Geometry", scene) val obj3d = Group()
node.setEnabled(false)
for (section in renderObject.sections) { for (section in renderObject.sections) {
val builder = VertexDataBuilder() val builder = MeshBuilder()
for (obj in section.objects) { for (obj in section.objects) {
ninjaObjectToVertexDataBuilder(obj, builder) ninjaObjectToMeshBuilder(obj, builder)
} }
val vertexData = builder.build() val mesh = builder.buildMesh()
val mesh = Mesh("Render Geometry", scene, node)
vertexData.applyToMesh(mesh)
// TODO: Material. // TODO: Material.
mesh.position = vec3ToBabylon(section.position) mesh.position.set(
mesh.rotation = vec3ToBabylon(section.rotation) section.position.x.toDouble(),
section.position.y.toDouble(),
section.position.z.toDouble()
)
mesh.rotation.set(
section.rotation.x.toDouble(),
section.rotation.y.toDouble(),
section.rotation.z.toDouble(),
)
mesh.updateMatrixWorld()
if (section.id >= 0) { if (section.id >= 0) {
val sec = SectionModel( val sec = SectionModel(
section.id, section.id,
vec3ToBabylon(section.position), vec3ToThree(section.position),
vec3ToBabylon(section.rotation), euler(section.rotation.x, section.rotation.y, section.rotation.z),
areaVariant, areaVariant,
) )
sections.add(sec) sections.add(sec)
mesh.metadata = AreaMetadata(sec)
}
} }
return Pair(node, sections) (mesh.userData.unsafeCast<AreaUserData>()).sectionId = section.id.takeIf { it >= 0 }
obj3d.add(mesh)
}
return Pair(obj3d, sections)
} }
// TODO: Use Geometry and not BufferGeometry for better raycaster performance.
private fun areaCollisionGeometryToTransformNode( private fun areaCollisionGeometryToTransformNode(
scene: Scene,
obj: CollisionObject, obj: CollisionObject,
episode: Episode, episode: Episode,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
): TransformNode { ): Object3D {
val node = TransformNode( val obj3d = Group()
"Collision Geometry $episode-${areaVariant.area.id}-${areaVariant.id}", obj3d.name = "Collision Geometry $episode-${areaVariant.area.id}-${areaVariant.id}"
scene
)
obj.meshes.forEachIndexed { i, collisionMesh -> for (collisionMesh in obj.meshes) {
val builder = VertexDataBuilder() val builder = MeshBuilder()
// TODO: Material.
val group = builder.getGroupIndex(textureId = null, alpha = false, additiveBlending = false)
for (triangle in collisionMesh.triangles) { for (triangle in collisionMesh.triangles) {
val isSectionTransition = (triangle.flags and 0b1000000) != 0 val isSectionTransition = (triangle.flags and 0b1000000) != 0
@ -250,30 +259,26 @@ private fun areaCollisionGeometryToTransformNode(
// Filter out walls. // Filter out walls.
if (colorIndex != 0) { if (colorIndex != 0) {
val p1 = vec3ToBabylon(collisionMesh.vertices[triangle.index1]) val p1 = vec3ToThree(collisionMesh.vertices[triangle.index1])
val p2 = vec3ToBabylon(collisionMesh.vertices[triangle.index2]) val p2 = vec3ToThree(collisionMesh.vertices[triangle.index2])
val p3 = vec3ToBabylon(collisionMesh.vertices[triangle.index3]) val p3 = vec3ToThree(collisionMesh.vertices[triangle.index3])
val n = vec3ToBabylon(triangle.normal) val n = vec3ToThree(triangle.normal)
builder.addIndex(builder.vertexCount) builder.addIndex(group, builder.vertexCount)
builder.addVertex(p1, n) builder.addVertex(p1, n)
builder.addIndex(builder.vertexCount) builder.addIndex(group, builder.vertexCount)
builder.addVertex(p3, n)
builder.addIndex(builder.vertexCount)
builder.addVertex(p2, n) builder.addVertex(p2, n)
builder.addIndex(group, builder.vertexCount)
builder.addVertex(p3, n)
} }
} }
if (builder.vertexCount > 0) { if (builder.vertexCount > 0) {
val mesh = Mesh( val mesh = builder.buildMesh(boundingVolumes = true)
"Collision Geometry $episode-${areaVariant.area.id}-${areaVariant.id}-$i", (mesh.userData.unsafeCast<CollisionUserData>()).collisionMesh = true
scene, obj3d.add(mesh)
parent = node
)
builder.build().applyToMesh(mesh)
mesh.metadata = CollisionMetadata()
} }
} }
return node return obj3d
} }

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import mu.KotlinLogging import mu.KotlinLogging
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import world.phantasmal.core.PwResult import world.phantasmal.core.PwResult
@ -9,66 +8,43 @@ import world.phantasmal.core.Success
import world.phantasmal.lib.Endianness import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.Cursor import world.phantasmal.lib.cursor.Cursor
import world.phantasmal.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.NinjaModel import world.phantasmal.lib.fileFormats.ninja.*
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.parseNj
import world.phantasmal.lib.fileFormats.ninja.parseXj
import world.phantasmal.lib.fileFormats.quest.EntityType import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.lib.fileFormats.quest.NpcType import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.lib.fileFormats.quest.ObjectType import world.phantasmal.lib.fileFormats.quest.ObjectType
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToVertexData import world.phantasmal.web.core.rendering.conversion.ninjaObjectToInstancedMesh
import world.phantasmal.web.externals.babylon.* import world.phantasmal.web.core.rendering.disposeObject3DResources
import world.phantasmal.web.externals.three.CylinderBufferGeometry
import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.externals.three.MeshLambertMaterial
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.obj
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class EntityAssetLoader( class EntityAssetLoader(
private val scope: CoroutineScope, scope: CoroutineScope,
private val assetLoader: AssetLoader, private val assetLoader: AssetLoader,
private val scene: Scene,
) : DisposableContainer() { ) : DisposableContainer() {
private val defaultMesh = private val instancedMeshCache = addDisposable(
MeshBuilder.CreateCylinder( LoadingCache<Pair<EntityType, Int?>, InstancedMesh>(
"Entity", scope,
obj { { (type, model) ->
diameter = 5.0
height = 18.0
},
scene
).apply {
setEnabled(false)
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 { try {
loadGeometry(type, model)?.let { vertexData -> loadMesh(type, model) ?: DEFAULT_MESH
val mesh = Mesh("${type.uniqueName}${model?.let { "-$it" }}", scene)
mesh.setEnabled(false)
vertexData.applyToMesh(mesh)
mesh
} ?: defaultMesh
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Couldn't load mesh for $type (model: $model)." } logger.error(e) { "Couldn't load mesh for $type (model: $model)." }
defaultMesh DEFAULT_MESH
} }
} },
}.await() ::disposeObject3DResources
)
)
private suspend fun loadGeometry(type: EntityType, model: Int?): VertexData? { suspend fun loadInstancedMesh(type: EntityType, model: Int?): InstancedMesh =
instancedMeshCache.get(Pair(type, model)).clone() as InstancedMesh
private suspend fun loadMesh(type: EntityType, model: Int?): InstancedMesh? {
val geomFormat = entityTypeToGeometryFormat(type) val geomFormat = entityTypeToGeometryFormat(type)
val geomParts = geometryParts(type).mapNotNull { suffix -> val geomParts = geometryParts(type).mapNotNull { suffix ->
@ -78,9 +54,44 @@ class EntityAssetLoader(
} }
} }
return when (geomFormat) { val ninjaObject = when (geomFormat) {
GeomFormat.Nj -> parseGeometry(type, geomParts, ::parseNj) GeomFormat.Nj -> parseGeometry(type, geomParts, ::parseNj)
GeomFormat.Xj -> parseGeometry(type, geomParts, ::parseXj) GeomFormat.Xj -> parseGeometry(type, geomParts, ::parseXj)
} ?: return null
val textures = loadTextures(type, model)
return ninjaObjectToInstancedMesh(
ninjaObject,
textures,
maxInstances = 300,
boundingVolumes = true,
)
}
private suspend fun loadTextures(type: EntityType, model: Int?): List<XvrTexture> {
val suffix =
if (
type === ObjectType.FloatingRocks ||
(type === ObjectType.BigBrownRock && model == undefined)
) {
"-0"
} else {
""
}
// GeomFormat is irrelevant for textures.
val path = entityTypeToPath(type, AssetType.Texture, suffix, model, GeomFormat.Nj)
?: return emptyList()
val buffer = assetLoader.loadArrayBuffer(path)
val xvm = parseXvm(buffer.cursor(endianness = Endianness.Little))
return if (xvm is Success) {
xvm.value.textures
} else {
logger.warn { "Couldn't parse $path for $type." }
emptyList()
} }
} }
@ -88,8 +99,8 @@ class EntityAssetLoader(
type: EntityType, type: EntityType,
parts: List<Pair<String, ArrayBuffer>>, parts: List<Pair<String, ArrayBuffer>>,
parse: (Cursor) -> PwResult<List<NinjaObject<Model>>>, parse: (Cursor) -> PwResult<List<NinjaObject<Model>>>,
): VertexData? { ): NinjaObject<Model>? {
val njObjects = parts.flatMap { (path, data) -> val ninjaObjects = parts.flatMap { (path, data) ->
val njObjects = parse(data.cursor(Endianness.Little)) val njObjects = parse(data.cursor(Endianness.Little))
if (njObjects is Success && njObjects.value.isNotEmpty()) { if (njObjects is Success && njObjects.value.isNotEmpty()) {
@ -100,18 +111,30 @@ class EntityAssetLoader(
} }
} }
if (njObjects.isEmpty()) { if (ninjaObjects.isEmpty()) {
return null return null
} }
val njObject = njObjects.first() val ninjaObject = ninjaObjects.first()
njObject.evaluationFlags.breakChildTrace = false ninjaObject.evaluationFlags.breakChildTrace = false
for (njObj in njObjects.drop(1)) { for (njObj in ninjaObjects.drop(1)) {
njObject.addChild(njObj) ninjaObject.addChild(njObj)
} }
return ninjaObjectToVertexData(njObject) return ninjaObject
}
companion object {
private val DEFAULT_MESH = InstancedMesh(
CylinderBufferGeometry(radiusTop = 2.5, radiusBottom = 2.5, height = 18.0).apply {
translate(0.0, 10.0, 0.0)
computeBoundingBox()
computeBoundingSphere()
},
MeshLambertMaterial(),
count = 1000,
)
} }
} }

View File

@ -1,19 +1,20 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import world.phantasmal.core.disposable.TrackedDisposable import world.phantasmal.core.disposable.TrackedDisposable
class LoadingCache<K, V>(private val disposeValue: (V) -> Unit) : TrackedDisposable() { class LoadingCache<K, V>(
private val scope: CoroutineScope,
private val loadValue: suspend (K) -> V,
private val disposeValue: (V) -> Unit,
) : TrackedDisposable() {
private val map = mutableMapOf<K, Deferred<V>>() private val map = mutableMapOf<K, Deferred<V>>()
operator fun set(key: K, value: Deferred<V>) { suspend fun get(key: K): V =
map[key] = value map.getOrPut(key) { scope.async { loadValue(key) } }.await()
}
@Suppress("DeferredIsResult")
fun getOrPut(key: K, defaultValue: () -> Deferred<V>): Deferred<V> =
map.getOrPut(key, defaultValue)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override fun internalDispose() { override fun internalDispose() {

View File

@ -1,26 +1,26 @@
package world.phantasmal.web.questEditor.loading package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.ArrayBuffer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.lib.Endianness import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.Quest import world.phantasmal.lib.fileFormats.quest.Quest
import world.phantasmal.lib.fileFormats.quest.parseQstToQuest import world.phantasmal.lib.fileFormats.quest.parseQstToQuest
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.webui.DisposableContainer
class QuestLoader( class QuestLoader(
private val scope: CoroutineScope, scope: CoroutineScope,
private val assetLoader: AssetLoader, private val assetLoader: AssetLoader,
) : TrackedDisposable() { ) : DisposableContainer() {
private val cache = LoadingCache<String, ArrayBuffer> {} private val cache = addDisposable(
LoadingCache<String, ArrayBuffer>(
override fun internalDispose() { scope,
cache.dispose() { path -> assetLoader.loadArrayBuffer("/quests$path") },
super.internalDispose() { /* Nothing to dispose. */ }
} )
)
suspend fun loadDefaultQuest(episode: Episode): Quest { suspend fun loadDefaultQuest(episode: Episode): Quest {
require(episode == Episode.I) { require(episode == Episode.I) {
@ -30,13 +30,6 @@ class QuestLoader(
return loadQuest("/defaults/default_ep_1.qst") return loadQuest("/defaults/default_ep_1.qst")
} }
private suspend fun loadQuest(path: String): Quest { private suspend fun loadQuest(path: String): Quest =
val buffer = cache.getOrPut(path) { parseQstToQuest(cache.get(path).cursor(Endianness.Little)).unwrap().quest
scope.async {
assetLoader.loadArrayBuffer("/quests$path")
}
}.await()
return parseQstToQuest(buffer.cursor(Endianness.Little)).unwrap().quest
}
} }

View File

@ -14,4 +14,12 @@ class AreaModel(
init { init {
requireNonNegative(id, "id") requireNonNegative(id, "id")
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class.js != other::class.js) return false
return id == (other as AreaModel).id
}
override fun hashCode(): Int = id
} }

View File

@ -16,4 +16,13 @@ class AreaVariantModel(val id: Int, val area: AreaModel) {
fun setSections(sections: List<SectionModel>) { fun setSections(sections: List<SectionModel>) {
_sections.replaceAll(sections) _sections.replaceAll(sections)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class.js != other::class.js) return false
other as AreaVariantModel
return id == other.id && area.id == other.area.id
}
override fun hashCode(): Int = 31 * id + area.hashCode()
} }

View File

@ -5,11 +5,14 @@ import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.lib.fileFormats.quest.QuestEntity import world.phantasmal.lib.fileFormats.quest.QuestEntity
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.* import world.phantasmal.web.core.euler
import world.phantasmal.web.core.rendering.conversion.babylonToVec3 import world.phantasmal.web.core.minus
import world.phantasmal.web.core.rendering.conversion.vec3ToBabylon import world.phantasmal.web.core.rendering.conversion.vec3ToThree
import world.phantasmal.web.externals.babylon.Quaternion import world.phantasmal.web.core.timesAssign
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.core.toEuler
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Quaternion
import world.phantasmal.web.externals.three.Vector3
import kotlin.math.PI import kotlin.math.PI
abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>( abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
@ -18,9 +21,9 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
private val _sectionId = mutableVal(entity.sectionId) private val _sectionId = mutableVal(entity.sectionId)
private val _section = mutableVal<SectionModel?>(null) private val _section = mutableVal<SectionModel?>(null)
private val _sectionInitialized = mutableVal(false) private val _sectionInitialized = mutableVal(false)
private val _position = mutableVal(vec3ToBabylon(entity.position)) private val _position = mutableVal(vec3ToThree(entity.position))
private val _worldPosition = mutableVal(_position.value) private val _worldPosition = mutableVal(_position.value)
private val _rotation = mutableVal(vec3ToBabylon(entity.rotation)) private val _rotation = entity.rotation.let { mutableVal(euler(it.x, it.y, it.z)) }
private val _worldRotation = mutableVal(_rotation.value) private val _worldRotation = mutableVal(_rotation.value)
val type: Type get() = entity.type val type: Type get() = entity.type
@ -42,9 +45,9 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
/** /**
* Section-relative rotation * Section-relative rotation
*/ */
val rotation: Val<Vector3> = _rotation val rotation: Val<Euler> = _rotation
val worldRotation: Val<Vector3> = _worldRotation val worldRotation: Val<Euler> = _worldRotation
fun setSection(section: SectionModel) { fun setSection(section: SectionModel) {
require(section.areaVariant.area.id == areaId) { require(section.areaVariant.area.id == areaId) {
@ -67,37 +70,34 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
} }
fun setPosition(pos: Vector3) { fun setPosition(pos: Vector3) {
entity.position = babylonToVec3(pos) entity.setPosition(pos.x.toFloat(), pos.y.toFloat(), pos.z.toFloat())
_position.value = pos _position.value = pos
val section = section.value val section = section.value
_worldPosition.value = _worldPosition.value =
section?.rotationQuaternion?.transformed(pos)?.also { if (section == null) pos
it += section.position else pos.clone().applyEuler(section.rotation).add(section.position)
} ?: pos
} }
fun setWorldPosition(pos: Vector3) { fun setWorldPosition(pos: Vector3) {
_worldPosition.value = pos
val section = section.value val section = section.value
val relPos = val relPos =
if (section == null) pos if (section == null) pos
else (pos - section.position).also { else (pos - section.position).applyEuler(section.inverseRotation)
section.inverseRotationQuaternion.transform(it)
}
entity.position = babylonToVec3(relPos) entity.setPosition(relPos.x.toFloat(), relPos.y.toFloat(), relPos.z.toFloat())
_worldPosition.value = pos
_position.value = relPos _position.value = relPos
} }
fun setRotation(rot: Vector3) { fun setRotation(rot: Euler) {
floorModEuler(rot) floorModEuler(rot)
entity.rotation = babylonToVec3(rot) entity.setRotation(rot.x.toFloat(), rot.y.toFloat(), rot.z.toFloat())
_rotation.value = rot _rotation.value = rot
val section = section.value val section = section.value
@ -105,55 +105,39 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
if (section == null) { if (section == null) {
_worldRotation.value = rot _worldRotation.value = rot
} else { } else {
Quaternion.FromEulerAnglesToRef(rot.x, rot.y, rot.z, q1) q1.setFromEuler(rot)
Quaternion.FromEulerAnglesToRef( q2.setFromEuler(section.rotation)
section.rotation.x,
section.rotation.y,
section.rotation.z,
q2
)
q1 *= q2 q1 *= q2
val worldRot = q1.toEulerAngles() _worldRotation.value = floorModEuler(q1.toEuler())
floorModEuler(worldRot)
_worldRotation.value = worldRot
} }
} }
fun setWorldRotation(rot: Vector3) { fun setWorldRotation(rot: Euler) {
floorModEuler(rot) floorModEuler(rot)
_worldRotation.value = rot
val section = section.value val section = section.value
val relRot = if (section == null) { val relRot = if (section == null) {
rot rot
} else { } else {
Quaternion.FromEulerAnglesToRef(rot.x, rot.y, rot.z, q1) q1.setFromEuler(rot)
Quaternion.FromEulerAnglesToRef( q2.setFromEuler(section.rotation)
section.rotation.x, q2.inverse()
section.rotation.y,
section.rotation.z,
q2
)
q2.invert()
q1 *= q2 q1 *= q2
val relRot = q1.toEulerAngles() floorModEuler(q1.toEuler())
floorModEuler(relRot)
relRot
} }
entity.rotation = babylonToVec3(relRot) entity.setRotation(relRot.x.toFloat(), relRot.y.toFloat(), relRot.z.toFloat())
_worldRotation.value = rot
_rotation.value = relRot _rotation.value = relRot
} }
private fun floorModEuler(euler: Vector3) { private fun floorModEuler(euler: Euler): Euler =
euler.set( euler.set(
floorMod(euler.x, 2 * PI), floorMod(euler.x, 2 * PI),
floorMod(euler.y, 2 * PI), floorMod(euler.y, 2 * PI),
floorMod(euler.z, 2 * PI), floorMod(euler.z, 2 * PI),
) )
}
companion object { companion object {
// These quaternions are used as temporary variables to avoid memory allocation. // These quaternions are used as temporary variables to avoid memory allocation.

View File

@ -1,13 +1,14 @@
package world.phantasmal.web.questEditor.models package world.phantasmal.web.questEditor.models
import world.phantasmal.web.core.inverse import world.phantasmal.web.core.toEuler
import world.phantasmal.web.externals.babylon.Quaternion import world.phantasmal.web.core.toQuaternion
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Vector3
class SectionModel( class SectionModel(
val id: Int, val id: Int,
val position: Vector3, val position: Vector3,
val rotation: Vector3, val rotation: Euler,
val areaVariant: AreaVariantModel, val areaVariant: AreaVariantModel,
) { ) {
init { init {
@ -16,8 +17,5 @@ class SectionModel(
} }
} }
val rotationQuaternion: Quaternion = val inverseRotation: Euler = rotation.toQuaternion().inverse().toEuler()
Quaternion.FromEulerAngles(rotation.x, rotation.y, rotation.z)
val inverseRotationQuaternion: Quaternion = rotationQuaternion.inverse()
} }

View File

@ -12,19 +12,14 @@ class AreaMeshManager(
private val areaAssetLoader: AreaAssetLoader, private val areaAssetLoader: AreaAssetLoader,
) { ) {
suspend fun load(episode: Episode?, areaVariant: AreaVariantModel?) { suspend fun load(episode: Episode?, areaVariant: AreaVariantModel?) {
renderer.collisionGeometry?.setEnabled(false) renderer.collisionGeometry = null
if (episode == null || areaVariant == null) { if (episode == null || areaVariant == null) {
return return
} }
try { try {
val geom = areaAssetLoader.loadCollisionGeometry(episode, areaVariant) renderer.collisionGeometry = areaAssetLoader.loadCollisionGeometry(episode, areaVariant)
// Call setEnabled(false) on renderer.collisionGeometry before calling setEnabled(true)
// on geom, because they can refer to the same object.
renderer.collisionGeometry?.setEnabled(false)
geom.setEnabled(true)
renderer.collisionGeometry = geom
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { logger.error(e) {
"Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}." "Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}."

View File

@ -3,10 +3,14 @@ package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mu.KotlinLogging import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.web.externals.babylon.AbstractMesh import world.phantasmal.web.externals.three.Group
import world.phantasmal.web.externals.babylon.TransformNode import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.externals.three.Mesh
import world.phantasmal.web.externals.three.Object3D
import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.loading.LoadingCache
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestNpcModel import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel import world.phantasmal.web.questEditor.models.QuestObjectModel
@ -19,58 +23,74 @@ private val logger = KotlinLogging.logger {}
class EntityMeshManager( class EntityMeshManager(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val questEditorStore: QuestEditorStore, private val questEditorStore: QuestEditorStore,
renderer: QuestRenderer, private val renderer: QuestRenderer,
private val entityAssetLoader: EntityAssetLoader, private val entityAssetLoader: EntityAssetLoader,
) : DisposableContainer() { ) : DisposableContainer() {
private val entityMeshes = Group().apply { name = "Entities" }
private val meshCache = addDisposable(
LoadingCache<CacheKey, InstancedMesh>(
scope,
{ (type, model) ->
val mesh = entityAssetLoader.loadInstancedMesh(type, model)
entityMeshes.add(mesh)
mesh
},
{ /* Nothing to dispose. */ },
)
)
private val queue: MutableList<QuestEntityModel<*, *>> = mutableListOf() private val queue: MutableList<QuestEntityModel<*, *>> = mutableListOf()
private val loadedEntities: MutableMap<QuestEntityModel<*, *>, LoadedEntity> = mutableMapOf() private val loadedEntities: MutableList<LoadedEntity> = mutableListOf()
private var loading = false private var loading = false
private var entityMeshes = TransformNode("Entities", renderer.scene) private var hoveredMesh: Mesh? = null
private var hoveredMesh: AbstractMesh? = null private var selectedMesh: Mesh? = null
private var selectedMesh: AbstractMesh? = null
init { init {
observe(questEditorStore.selectedEntity) { entity -> renderer.scene.add(entityMeshes)
if (entity == null) {
unmarkSelected()
} else {
val loaded = loadedEntities[entity]
// Mesh might not be loaded yet. // observe(questEditorStore.selectedEntity) { entity ->
if (loaded == null) { // if (entity == null) {
unmarkSelected() // unmarkSelected()
} else { // } else {
markSelected(loaded.mesh) // val loaded = loadedEntities[entity]
} //
} // // Mesh might not be loaded yet.
} // if (loaded == null) {
// unmarkSelected()
// } else {
// markSelected(loaded.mesh)
// }
// }
// }
} }
override fun internalDispose() { override fun internalDispose() {
entityMeshes.dispose() renderer.scene.remove(entityMeshes)
removeAll() removeAll()
entityMeshes.clear()
super.internalDispose() super.internalDispose()
} }
fun add(entities: List<QuestEntityModel<*, *>>) { fun add(entity: QuestEntityModel<*, *>) {
queue.addAll(entities) queue.add(entity)
if (!loading) { if (!loading) {
scope.launch {
try {
loading = true loading = true
scope.launch {
try {
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
val entity = queue.first() val queuedEntity = queue.first()
try { try {
load(entity) load(queuedEntity)
} catch (e: Error) { } catch (e: Error) {
logger.error(e) { logger.error(e) {
"Couldn't load model for entity of type ${entity.type}." "Couldn't load model for entity of type ${queuedEntity.type}."
} }
queue.remove(entity) queue.remove(queuedEntity)
} }
} }
} finally { } finally {
@ -80,16 +100,27 @@ class EntityMeshManager(
} }
} }
fun remove(entities: List<QuestEntityModel<*, *>>) { fun remove(entity: QuestEntityModel<*, *>) {
for (entity in entities) {
queue.remove(entity) queue.remove(entity)
loadedEntities.remove(entity)?.dispose() val idx = loadedEntities.indexOfFirst { it.entity == entity }
if (idx != -1) {
val loaded = loadedEntities.removeAt(idx)
loaded.mesh.count--
for (i in idx until loaded.mesh.count) {
loaded.mesh.instanceMatrix.copyAt(i, loaded.mesh.instanceMatrix, i + 1)
loadedEntities[i].instanceIndex = i
}
loaded.dispose()
} }
} }
fun removeAll() { fun removeAll() {
for (loaded in loadedEntities.values) { for (loaded in loadedEntities) {
loaded.mesh.count = 0
loaded.dispose() loaded.dispose()
} }
@ -97,59 +128,64 @@ class EntityMeshManager(
queue.clear() queue.clear()
} }
private fun markSelected(entityMesh: AbstractMesh) { // private fun markSelected(entityMesh: AbstractMesh) {
if (entityMesh == hoveredMesh) { // if (entityMesh == hoveredMesh) {
hoveredMesh = null // hoveredMesh = null
} // }
//
if (entityMesh != selectedMesh) { // if (entityMesh != selectedMesh) {
selectedMesh?.let { it.showBoundingBox = false } // selectedMesh?.let { it.showBoundingBox = false }
//
entityMesh.showBoundingBox = true // entityMesh.showBoundingBox = true
} // }
//
selectedMesh = entityMesh // selectedMesh = entityMesh
} // }
//
private fun unmarkSelected() { // private fun unmarkSelected() {
selectedMesh?.let { it.showBoundingBox = false } // selectedMesh?.let { it.showBoundingBox = false }
selectedMesh = null // selectedMesh = null
} // }
private suspend fun load(entity: QuestEntityModel<*, *>) { private suspend fun load(entity: QuestEntityModel<*, *>) {
val mesh = entityAssetLoader.loadMesh( val mesh = meshCache.get(CacheKey(
type = entity.type, type = entity.type,
model = (entity as? QuestObjectModel)?.model?.value model = (entity as? QuestObjectModel)?.model?.value
) ))
// Only add an instance of this mesh if the entity is still in the queue at this point. // Only add an instance of this mesh if the entity is still in the queue at this point.
if (queue.remove(entity)) { if (queue.remove(entity)) {
val instance = mesh.createInstance(entity.type.uniqueName) val instanceIndex = mesh.count
instance.parent = entityMeshes mesh.count++
if (entity == questEditorStore.selectedEntity.value) { // if (entity == questEditorStore.selectedEntity.value) {
markSelected(instance) // markSelected(instance)
// }
loadedEntities.add(LoadedEntity(
entity,
mesh,
instanceIndex,
questEditorStore.selectedWave
))
}
} }
loadedEntities[entity] = LoadedEntity(entity, instance, questEditorStore.selectedWave) private data class CacheKey(val type: EntityType, val model: Int?)
}
}
private inner class LoadedEntity( private inner class LoadedEntity(
entity: QuestEntityModel<*, *>, val entity: QuestEntityModel<*, *>,
val mesh: AbstractMesh, val mesh: InstancedMesh,
var instanceIndex: Int,
selectedWave: Val<WaveModel?>, selectedWave: Val<WaveModel?>,
) : DisposableContainer() { ) : DisposableContainer() {
init { init {
mesh.metadata = EntityMetadata(entity) updateMatrix()
observe(entity.worldPosition) { pos -> addDisposables(
mesh.position = pos entity.worldPosition.observe { updateMatrix() },
} entity.worldRotation.observe { updateMatrix() },
)
observe(entity.worldRotation) { rot ->
mesh.rotation = rot
}
val isVisible: Val<Boolean> val isVisible: Val<Boolean>
@ -166,21 +202,40 @@ class EntityMeshManager(
if (entity is QuestObjectModel) { if (entity is QuestObjectModel) {
addDisposable(entity.model.observe(callNow = false) { addDisposable(entity.model.observe(callNow = false) {
remove(listOf(entity)) remove(entity)
add(listOf(entity)) add(entity)
}) })
} }
} }
observe(isVisible) { visible -> // observe(isVisible) { visible ->
mesh.setEnabled(visible) // mesh.setEnabled(visible)
} // }
} }
override fun internalDispose() { override fun internalDispose() {
mesh.parent = null // TODO: Dispose instance.
mesh.dispose()
super.internalDispose() super.internalDispose()
} }
private fun updateMatrix() {
instanceHelper.position.set(
entity.worldPosition.value.x,
entity.worldPosition.value.y,
entity.worldPosition.value.z,
)
instanceHelper.rotation.set(
entity.worldRotation.value.x,
entity.worldRotation.value.y,
entity.worldRotation.value.z,
)
instanceHelper.updateMatrix()
mesh.setMatrixAt(instanceIndex, instanceHelper.matrix)
mesh.instanceMatrix.needsUpdate = true
}
}
companion object {
private val instanceHelper = Object3D()
} }
} }

View File

@ -4,4 +4,6 @@ import world.phantasmal.web.questEditor.models.QuestEntityModel
class EntityMetadata(val entity: QuestEntityModel<*, *>) class EntityMetadata(val entity: QuestEntityModel<*, *>)
class CollisionMetadata interface CollisionUserData {
var collisionMesh: Boolean
}

View File

@ -17,7 +17,7 @@ class QuestEditorMeshManager(
entityAssetLoader: EntityAssetLoader, entityAssetLoader: EntityAssetLoader,
) : QuestMeshManager(scope, questEditorStore, renderer, areaAssetLoader, entityAssetLoader) { ) : QuestMeshManager(scope, questEditorStore, renderer, areaAssetLoader, entityAssetLoader) {
init { init {
disposer.addAll( addDisposables(
questEditorStore.currentQuest.map(questEditorStore.currentArea, ::getAreaVariantDetails) questEditorStore.currentQuest.map(questEditorStore.currentArea, ::getAreaVariantDetails)
.observe { (details) -> .observe { (details) ->
loadMeshes(details.episode, details.areaVariant, details.npcs, details.objects) loadMeshes(details.episode, details.areaVariant, details.npcs, details.objects)

View File

@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.lib.fileFormats.quest.Episode import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.ListValChangeEvent import world.phantasmal.observable.value.list.ListValChangeEvent
@ -14,6 +13,7 @@ import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.models.QuestNpcModel import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.DisposableContainer
/** /**
* Loads the necessary area and entity 3D models into [QuestRenderer]. * Loads the necessary area and entity 3D models into [QuestRenderer].
@ -24,15 +24,13 @@ abstract class QuestMeshManager protected constructor(
private val renderer: QuestRenderer, private val renderer: QuestRenderer,
areaAssetLoader: AreaAssetLoader, areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader, entityAssetLoader: EntityAssetLoader,
) : TrackedDisposable() { ) : DisposableContainer() {
protected val disposer = Disposer() private val areaDisposer = addDisposable(Disposer())
private val areaDisposer = disposer.add(Disposer())
private val areaMeshManager = AreaMeshManager(renderer, areaAssetLoader) private val areaMeshManager = AreaMeshManager(renderer, areaAssetLoader)
private val npcMeshManager = disposer.add( private val npcMeshManager = addDisposable(
EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader) EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader)
) )
private val objectMeshManager = disposer.add( private val objectMeshManager = addDisposable(
EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader) EntityMeshManager(scope, questEditorStore, renderer, entityAssetLoader)
) )
@ -64,22 +62,17 @@ abstract class QuestMeshManager protected constructor(
} }
} }
override fun internalDispose() {
disposer.dispose()
super.internalDispose()
}
private fun npcsChanged(change: ListValChangeEvent<QuestNpcModel>) { private fun npcsChanged(change: ListValChangeEvent<QuestNpcModel>) {
if (change is ListValChangeEvent.Change) { if (change is ListValChangeEvent.Change) {
npcMeshManager.remove(change.removed) change.removed.forEach(npcMeshManager::remove)
npcMeshManager.add(change.inserted) change.inserted.forEach(npcMeshManager::add)
} }
} }
private fun objectsChanged(change: ListValChangeEvent<QuestObjectModel>) { private fun objectsChanged(change: ListValChangeEvent<QuestObjectModel>) {
if (change is ListValChangeEvent.Change) { if (change is ListValChangeEvent.Change) {
objectMeshManager.remove(change.removed) change.removed.forEach(objectMeshManager::remove)
objectMeshManager.add(change.inserted) change.inserted.forEach(objectMeshManager::add)
} }
} }
} }

View File

@ -1,71 +1,45 @@
package world.phantasmal.web.questEditor.rendering package world.phantasmal.web.questEditor.rendering
import org.w3c.dom.HTMLCanvasElement import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.rendering.Renderer import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.externals.babylon.ArcRotateCamera import world.phantasmal.web.externals.three.Object3D
import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.externals.three.PerspectiveCamera
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) { class QuestRenderer(
override val camera = ArcRotateCamera("Camera", PI / 2, PI / 6, 500.0, Vector3.Zero(), scene) createThreeRenderer: () -> DisposableThreeRenderer,
) : Renderer(
var collisionGeometry: TransformNode? = null createThreeRenderer,
PerspectiveCamera(
fov = 45.0,
aspect = 1.0,
near = 10.0,
far = 5_000.0
)
) {
var collisionGeometry: Object3D? = null
set(geom) {
field?.let { scene.remove(it) }
field = geom
geom?.let { scene.add(it) }
}
init { init {
with(camera) { camera.position.set(0.0, 50.0, 200.0)
inertia = 0.0 controls.update()
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
panningAxis = Vector3(1.0, 0.0, -1.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1
updatePanningSensibility() controls.screenSpacePanning = false
onViewMatrixChangedObservable.add({ _, _ ->
updatePanningSensibility()
})
enableCameraControls()
camera.storeState()
}
} }
fun resetCamera() { fun resetCamera() {
camera.restoreState()
} }
fun enableCameraControls() { fun enableCameraControls() {
camera.attachControl(
canvas,
noPreventDefault = false,
useCtrlForPanning = false,
panningMouseButton = 0
)
} }
fun disableCameraControls() { fun disableCameraControls() {
camera.detachControl()
} }
override fun render() { override fun render() {
camera.minZ = max(0.01, camera.radius / 100)
camera.maxZ = max(2_000.0, 10 * camera.radius)
super.render() 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

@ -4,10 +4,10 @@ import kotlinx.browser.document
import mu.KotlinLogging import mu.KotlinLogging
import org.w3c.dom.pointerevents.PointerEvent import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.web.core.minus import world.phantasmal.web.externals.three.Intersection
import world.phantasmal.web.core.plusAssign import world.phantasmal.web.externals.three.Raycaster
import world.phantasmal.web.core.times import world.phantasmal.web.externals.three.Vector2
import world.phantasmal.web.externals.babylon.* import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.actions.TranslateEntityAction import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel import world.phantasmal.web.questEditor.models.SectionModel
@ -17,16 +17,18 @@ import world.phantasmal.webui.dom.disposableListener
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val ZERO_VECTOR = Vector3.Zero() private val ZERO_VECTOR = Vector3(0.0, 0.0, 0.0)
private val DOWN_VECTOR = Vector3.Down() private val DOWN_VECTOR = Vector3(0.0, -1.0, 0.0)
private val raycaster = Raycaster()
class UserInputManager( class UserInputManager(
questEditorStore: QuestEditorStore, questEditorStore: QuestEditorStore,
private val renderer: QuestRenderer, private val renderer: QuestRenderer,
) : DisposableContainer() { ) : DisposableContainer() {
private val stateContext = StateContext(questEditorStore, renderer) private val stateContext = StateContext(questEditorStore, renderer)
private val pointerPosition = Vector2.Zero() private val pointerPosition = Vector2()
private val lastPointerPosition = Vector2.Zero() private val lastPointerPosition = Vector2()
private var movedSinceLastPointerDown = false private var movedSinceLastPointerDown = false
private var state: State private var state: State
private var onPointerUpListener: Disposable? = null private var onPointerUpListener: Disposable? = null
@ -128,7 +130,7 @@ class UserInputManager(
} }
} }
lastPointerPosition.copyFrom(pointerPosition) lastPointerPosition.copy(pointerPosition)
} }
} }
@ -136,8 +138,8 @@ private class StateContext(
private val questEditorStore: QuestEditorStore, private val questEditorStore: QuestEditorStore,
val renderer: QuestRenderer, val renderer: QuestRenderer,
) { ) {
private val plane = Plane.FromPositionAndNormal(Vector3.Up(), Vector3.Up()) // private val plane = Plane.FromPositionAndNormal(Vector3.Up(), Vector3.Up())
private val ray = Ray.Zero() // private val ray = Ray.Zero()
val scene = renderer.scene val scene = renderer.scene
@ -154,7 +156,7 @@ private class StateContext(
if (vertically) { if (vertically) {
// TODO: Vertical translation. // TODO: Vertical translation.
} else { } else {
translateEntityHorizontally(entity, dragAdjust, grabOffset) // translateEntityHorizontally(entity, dragAdjust, grabOffset)
} }
} }
@ -181,79 +183,79 @@ private class StateContext(
* If the drag-adjusted pointer is over the ground, translate an entity horizontally across the * 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. * ground. Otherwise translate the entity over the horizontal plane that intersects its origin.
*/ */
private fun translateEntityHorizontally( // private fun translateEntityHorizontally(
entity: QuestEntityModel<*, *>, // entity: QuestEntityModel<*, *>,
dragAdjust: Vector3, // dragAdjust: Vector3,
grabOffset: Vector3, // grabOffset: Vector3,
) { // ) {
val pick = pickGround(scene.pointerX, scene.pointerY, dragAdjust) // val pick = pickGround(scene.pointerX, scene.pointerY, dragAdjust)
//
if (pick == null) { // if (pick == null) {
// If the pointer is not over the ground, we translate the entity across the horizontal // // If the pointer is not over the ground, we translate the entity across the horizontal
// plane in which the entity's origin lies. // // plane in which the entity's origin lies.
scene.createPickingRayToRef( // scene.createPickingRayToRef(
scene.pointerX, // scene.pointerX,
scene.pointerY, // scene.pointerY,
Matrix.IdentityReadOnly, // Matrix.IdentityReadOnly,
ray, // ray,
renderer.camera // renderer.camera
) // )
//
plane.d = -entity.worldPosition.value.y + grabOffset.y // plane.d = -entity.worldPosition.value.y + grabOffset.y
//
ray.intersectsPlane(plane)?.let { distance -> // ray.intersectsPlane(plane)?.let { distance ->
// Compute the intersection point. // // Compute the intersection point.
val pos = ray.direction * distance // val pos = ray.direction * distance
pos += ray.origin // pos += ray.origin
// Compute the entity's new world position. // // Compute the entity's new world position.
pos.x += grabOffset.x // pos.x += grabOffset.x
pos.y = entity.worldPosition.value.y // pos.y = entity.worldPosition.value.y
pos.z += grabOffset.z // pos.z += grabOffset.z
//
entity.setWorldPosition(pos) // entity.setWorldPosition(pos)
} // }
} else { // } else {
// TODO: Set entity section. // // TODO: Set entity section.
entity.setWorldPosition( // entity.setWorldPosition(
Vector3( // Vector3(
pick.pickedPoint!!.x, // pick.pickedPoint!!.x,
pick.pickedPoint.y + grabOffset.y - dragAdjust.y, // pick.pickedPoint.y + grabOffset.y - dragAdjust.y,
pick.pickedPoint.z, // pick.pickedPoint.z,
) // )
) // )
} // }
} // }
//
fun pickGround(x: Double, y: Double, dragAdjust: Vector3 = ZERO_VECTOR): PickingInfo? { // fun pickGround(x: Double, y: Double, dragAdjust: Vector3 = ZERO_VECTOR): PickingInfo? {
scene.createPickingRayToRef( // scene.createPickingRayToRef(
x, // x,
y, // y,
Matrix.IdentityReadOnly, // Matrix.IdentityReadOnly,
ray, // ray,
renderer.camera // renderer.camera
) // )
//
ray.origin += dragAdjust // ray.origin += dragAdjust
//
val pickingInfoArray = scene.multiPickWithRay( // val pickingInfoArray = scene.multiPickWithRay(
ray, // ray,
{ it.isEnabled() && it.metadata is CollisionMetadata }, // { it.isEnabled() && it.metadata is CollisionUserData },
) // )
//
if (pickingInfoArray != null) { // if (pickingInfoArray != null) {
for (pickingInfo in pickingInfoArray) { // for (pickingInfo in pickingInfoArray) {
pickingInfo.getNormal()?.let { n -> // pickingInfo.getNormal()?.let { n ->
// Don't allow entities to be placed on very steep terrain. E.g. walls. // // 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. // // TODO: make use of the flags field in the collision data.
if (n.y > 0.75) { // if (n.y > 0.75) {
return pickingInfo // return pickingInfo
} // }
} // }
} // }
} // }
//
return null // return null
} // }
} }
private sealed class Evt private sealed class Evt
@ -284,7 +286,7 @@ private class PointerMoveEvt(
private class Pick( private class Pick(
val entity: QuestEntityModel<*, *>, val entity: QuestEntityModel<*, *>,
val mesh: AbstractMesh, // val mesh: AbstractMesh,
/** /**
* Vector that points from the grabbing point (somewhere on the model's surface) to the entity's * Vector that points from the grabbing point (somewhere on the model's surface) to the entity's
@ -319,40 +321,40 @@ private class IdleState(
) : State() { ) : State() {
override fun processEvent(event: Evt): State { override fun processEvent(event: Evt): State {
when (event) { when (event) {
is PointerDownEvt -> { // is PointerDownEvt -> {
pickEntity()?.let { pick -> // pickEntity()?.let { pick ->
when (event.buttons) { // when (event.buttons) {
1 -> { // 1 -> {
ctx.setSelectedEntity(pick.entity) // ctx.setSelectedEntity(pick.entity)
//
// if (entityManipulationEnabled) {
// return TranslationState(
// ctx,
// pick.entity,
// pick.dragAdjust,
// pick.grabOffset
// )
// }
// }
// 2 -> {
// ctx.setSelectedEntity(pick.entity)
//
// if (entityManipulationEnabled) {
// // TODO: Enter RotationState.
// }
// }
// }
// }
// }
if (entityManipulationEnabled) { // is PointerUpEvt -> {
return TranslationState( // updateCameraTarget()
ctx, //
pick.entity, // // If the user clicks on nothing, deselect the currently selected entity.
pick.dragAdjust, // if (!event.movedSinceLastPointerDown && pickEntity() == null) {
pick.grabOffset // ctx.setSelectedEntity(null)
) // }
} // }
}
2 -> {
ctx.setSelectedEntity(pick.entity)
if (entityManipulationEnabled) {
// TODO: Enter RotationState.
}
}
}
}
}
is PointerUpEvt -> {
updateCameraTarget()
// If the user clicks on nothing, deselect the currently selected entity.
if (!event.movedSinceLastPointerDown && pickEntity() == null) {
ctx.setSelectedEntity(null)
}
}
else -> { else -> {
// Do nothing. // Do nothing.
@ -369,44 +371,48 @@ private class IdleState(
private fun updateCameraTarget() { private fun updateCameraTarget() {
// If the user moved the camera, try setting the camera // If the user moved the camera, try setting the camera
// target to a better point. // target to a better point.
ctx.pickGround( // ctx.pickGround(
ctx.renderer.engine.getRenderWidth() / 2, // ctx.renderer.engine.getRenderWidth() / 2,
ctx.renderer.engine.getRenderHeight() / 2, // ctx.renderer.engine.getRenderHeight() / 2,
)?.pickedPoint?.let { newTarget -> // )?.pickedPoint?.let { newTarget ->
ctx.renderer.camera.target = newTarget // ctx.renderer.camera.target = newTarget
} // }
} }
private fun pickEntity(): Pick? { /**
// Find the nearest object and NPC under the pointer. * @param pointerPosition pointer coordinates in normalized device space
val pickInfo = ctx.scene.pick(ctx.scene.pointerX, ctx.scene.pointerY) */
if (pickInfo?.pickedMesh == null) return null // private fun pickEntity(pointerPosition:Vector2): Pick? {
// // Find the nearest object and NPC under the pointer.
val entity = (pickInfo.pickedMesh.metadata as? EntityMetadata)?.entity // raycaster.setFromCamera(pointerPosition, ctx.renderer.camera)
?: return null // val pickInfo = ctx.scene.pick(ctx.scene.pointerX, ctx.scene.pointerY)
// if (pickInfo?.pickedMesh == null) return null
// Vector from the point where we grab the entity to its position. //
val grabOffset = pickInfo.pickedMesh.position - pickInfo.pickedPoint!! // val entity = (pickInfo.pickedMesh.metadata as? EntityMetadata)?.entity
// ?: return null
// 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. // // Vector from the point where we grab the entity to its position.
val dragAdjust = grabOffset.clone() // val grabOffset = pickInfo.pickedMesh.position - pickInfo.pickedPoint!!
//
// Find vertical distance to the ground. // // Vector from the point where we grab the entity to the point on the ground right beneath
ctx.scene.pickWithRay( // // its position. The same as grabOffset when an entity is standing on the ground.
Ray(pickInfo.pickedMesh.position, DOWN_VECTOR), // val dragAdjust = grabOffset.clone()
{ it.isEnabled() && it.metadata is CollisionMetadata }, //
)?.let { groundPick -> // // Find vertical distance to the ground.
dragAdjust.y -= groundPick.distance // ctx.scene.pickWithRay(
} // Ray(pickInfo.pickedMesh.position, DOWN_VECTOR),
// { it.isEnabled() && it.metadata is CollisionUserData },
return Pick( // )?.let { groundPick ->
entity, // dragAdjust.y -= groundPick.distance
pickInfo.pickedMesh, // }
grabOffset, //
dragAdjust, // return Pick(
) // entity,
} // pickInfo.pickedMesh,
// grabOffset,
// dragAdjust,
// )
// }
} }
private class TranslationState( private class TranslationState(

View File

@ -6,6 +6,5 @@ import world.phantasmal.web.questEditor.rendering.QuestRenderer
class QuestEditorRendererWidget( class QuestEditorRendererWidget(
scope: CoroutineScope, scope: CoroutineScope,
canvas: HTMLCanvasElement,
renderer: QuestRenderer, renderer: QuestRenderer,
) : QuestRendererWidget(scope, canvas, renderer) ) : QuestRendererWidget(scope, renderer)

View File

@ -62,7 +62,7 @@ class QuestEditorWidget(
), ),
) )
), ),
DockedRow( DockedStack(
flex = 9, flex = 9,
items = listOf( items = listOf(
DockedWidget( DockedWidget(

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.questEditor.widgets package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.RendererWidget import world.phantasmal.web.core.widgets.RendererWidget
import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.rendering.QuestRenderer
@ -10,7 +9,6 @@ import world.phantasmal.webui.widgets.Widget
abstract class QuestRendererWidget( abstract class QuestRendererWidget(
scope: CoroutineScope, scope: CoroutineScope,
private val canvas: HTMLCanvasElement,
private val renderer: QuestRenderer, private val renderer: QuestRenderer,
) : Widget(scope) { ) : Widget(scope) {
override fun Node.createElement() = override fun Node.createElement() =
@ -18,7 +16,7 @@ abstract class QuestRendererWidget(
className = "pw-quest-editor-quest-renderer" className = "pw-quest-editor-quest-renderer"
tabIndex = -1 tabIndex = -1
addChild(RendererWidget(scope, canvas, renderer)) addChild(RendererWidget(scope, renderer))
} }
companion object { companion object {

View File

@ -1,13 +1,14 @@
package world.phantasmal.web.viewer package world.phantasmal.web.viewer
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.widgets.RendererWidget
import world.phantasmal.web.viewer.controller.ViewerController
import world.phantasmal.web.viewer.controller.ViewerToolbarController import world.phantasmal.web.viewer.controller.ViewerToolbarController
import world.phantasmal.web.viewer.rendering.MeshRenderer import world.phantasmal.web.viewer.rendering.MeshRenderer
import world.phantasmal.web.viewer.rendering.TextureRenderer
import world.phantasmal.web.viewer.store.ViewerStore import world.phantasmal.web.viewer.store.ViewerStore
import world.phantasmal.web.viewer.widgets.ViewerToolbar import world.phantasmal.web.viewer.widgets.ViewerToolbar
import world.phantasmal.web.viewer.widgets.ViewerWidget import world.phantasmal.web.viewer.widgets.ViewerWidget
@ -15,7 +16,7 @@ import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class Viewer( class Viewer(
private val createEngine: (HTMLCanvasElement) -> Engine, private val createThreeRenderer: () -> DisposableThreeRenderer,
) : DisposableContainer(), PwTool { ) : DisposableContainer(), PwTool {
override val toolType = PwToolType.Viewer override val toolType = PwToolType.Viewer
@ -24,19 +25,24 @@ class Viewer(
val viewerStore = addDisposable(ViewerStore(scope)) val viewerStore = addDisposable(ViewerStore(scope))
// Controllers // Controllers
val viewerController = addDisposable(ViewerController())
val viewerToolbarController = addDisposable(ViewerToolbarController(viewerStore)) val viewerToolbarController = addDisposable(ViewerToolbarController(viewerStore))
// Rendering // Rendering
val canvas = document.createElement("CANVAS") as HTMLCanvasElement val meshRenderer = addDisposable(
canvas.style.outline = "none" MeshRenderer(viewerStore, createThreeRenderer)
val renderer = addDisposable(MeshRenderer(viewerStore, canvas, createEngine(canvas))) )
val textureRenderer = addDisposable(
TextureRenderer(viewerStore, createThreeRenderer)
)
// Main Widget // Main Widget
return ViewerWidget( return ViewerWidget(
scope, scope,
viewerController,
{ s -> ViewerToolbar(s, viewerToolbarController) }, { s -> ViewerToolbar(s, viewerToolbarController) },
canvas, { s -> RendererWidget(s, meshRenderer) },
renderer { s -> RendererWidget(s, textureRenderer) },
) )
} }
} }

View File

@ -0,0 +1,13 @@
package world.phantasmal.web.viewer.controller
import world.phantasmal.webui.controllers.Tab
import world.phantasmal.webui.controllers.TabController
sealed class ViewerTab(override val title: String) : Tab {
object Mesh : ViewerTab("Model")
object Texture : ViewerTab("Texture")
}
class ViewerController : TabController<ViewerTab>(
listOf(ViewerTab.Mesh, ViewerTab.Texture)
)

View File

@ -10,6 +10,7 @@ import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.parseNj import world.phantasmal.lib.fileFormats.ninja.parseNj
import world.phantasmal.lib.fileFormats.ninja.parseXj import world.phantasmal.lib.fileFormats.ninja.parseXj
import world.phantasmal.lib.fileFormats.ninja.parseXvm
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.viewer.store.ViewerStore import world.phantasmal.web.viewer.store.ViewerStore
@ -26,8 +27,10 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
val resultDialogVisible: Val<Boolean> = _resultDialogVisible val resultDialogVisible: Val<Boolean> = _resultDialogVisible
val result: Val<PwResult<*>?> = _result val result: Val<PwResult<*>?> = _result
val resultMessage: Val<String> = result.map { val resultMessage: Val<String> = result.map {
if (it is Failure) "An error occurred while opening files." when (it) {
else "Encountered some problems while opening files." is Success, null -> "Encountered some problems while opening files."
is Failure -> "An error occurred while opening files."
}
} }
suspend fun openFiles(files: List<File>) { suspend fun openFiles(files: List<File>) {
@ -66,6 +69,19 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
} }
} }
"xvm" -> {
if (textureFound) continue
textureFound = true
val xvmResult = parseXvm(readFile(file).cursor(Endianness.Little))
result.addResult(xvmResult)
if (xvmResult is Success) {
store.setCurrentTextures(xvmResult.value.textures)
success = true
}
}
else -> { else -> {
result.addProblem( result.addProblem(
Severity.Error, Severity.Error,

View File

@ -1,63 +1,53 @@
package world.phantasmal.web.viewer.rendering package world.phantasmal.web.viewer.rendering
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.lib.fileFormats.ninja.NinjaObject import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.core.rendering.DisposableThreeRenderer
import world.phantasmal.web.core.rendering.Renderer import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToVertexData import world.phantasmal.web.core.rendering.conversion.ninjaObjectToMesh
import world.phantasmal.web.externals.babylon.ArcRotateCamera import world.phantasmal.web.core.rendering.disposeObject3DResources
import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.externals.three.BufferGeometry
import world.phantasmal.web.externals.babylon.Mesh import world.phantasmal.web.externals.three.Mesh
import world.phantasmal.web.externals.babylon.Vector3 import world.phantasmal.web.externals.three.PerspectiveCamera
import world.phantasmal.web.viewer.store.ViewerStore import world.phantasmal.web.viewer.store.ViewerStore
import kotlin.math.PI
class MeshRenderer( class MeshRenderer(
store: ViewerStore, store: ViewerStore,
canvas: HTMLCanvasElement, createThreeRenderer: () -> DisposableThreeRenderer,
engine: Engine, ) : Renderer(
) : Renderer(canvas, engine) { createThreeRenderer,
PerspectiveCamera(
fov = 45.0,
aspect = 1.0,
near = 1.0,
far = 1_000.0,
)
) {
private var mesh: Mesh? = null private var mesh: Mesh? = null
override val camera = ArcRotateCamera("Camera", PI / 2, PI / 3, 70.0, Vector3.Zero(), scene)
init { init {
with(camera) { camera.position.set(0.0, 50.0, 200.0)
attachControl( controls.update()
canvas,
noPreventDefault = false, controls.screenSpacePanning = true
useCtrlForPanning = false,
panningMouseButton = 0 observe(store.currentNinjaObject, store.currentTextures, ::ninjaObjectOrXvmChanged)
)
inertia = 0.0
angularSensibilityX = 200.0
angularSensibilityY = 200.0
panningInertia = 0.0
panningSensibility = 10.0
panningAxis = Vector3(1.0, 1.0, 0.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1
} }
observe(store.currentNinjaObject, ::ninjaObjectOrXvmChanged) private fun ninjaObjectOrXvmChanged(ninjaObject: NinjaObject<*>?, textures: List<XvrTexture>) {
mesh?.let { mesh ->
disposeObject3DResources(mesh)
scene.remove(mesh)
} }
override fun internalDispose() {
mesh?.dispose()
super.internalDispose()
}
private fun ninjaObjectOrXvmChanged(ninjaObject: NinjaObject<*>?) {
mesh?.dispose()
if (ninjaObject != null) { if (ninjaObject != null) {
val mesh = Mesh("Model", scene) val mesh = ninjaObjectToMesh(ninjaObject, textures, boundingVolumes = true)
val vertexData = ninjaObjectToVertexData(ninjaObject)
vertexData.applyToMesh(mesh)
// Make sure we rotate around the center of the model instead of its origin. // Make sure we rotate around the center of the model instead of its origin.
val bb = mesh.getBoundingInfo().boundingBox val bb = (mesh.geometry as BufferGeometry).boundingBox!!
val height = bb.maximum.y - bb.minimum.y val height = bb.max.y - bb.min.y
mesh.position = mesh.position.addInPlaceFromFloats(0.0, -height / 2 - bb.minimum.y, 0.0) mesh.translateY(-height / 2 - bb.min.y)
scene.add(mesh)
this.mesh = mesh this.mesh = mesh
} }

View File

@ -0,0 +1,71 @@
package world.phantasmal.web.viewer.rendering
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.core.rendering.DisposableThreeRenderer
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
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,
)
) {
private var meshes = listOf<Mesh>()
init {
observe(store.currentTextures, ::texturesChanged)
}
private fun texturesChanged(textures: List<XvrTexture>) {
meshes.forEach { mesh ->
disposeObject3DResources(mesh)
scene.remove(mesh)
}
var x = 0.0
meshes = textures.map { xvr ->
val quad = Mesh(
createQuad(x, 0.0, xvr.width, xvr.height),
MeshBasicMaterial(obj {
map = xvrTextureToThree(xvr, filter = NearestFilter)
transparent = true
})
)
scene.add(quad)
x += xvr.width + 10.0
quad
}
}
private fun createQuad(x: Double, y: Double, width: Int, height: Int): PlaneGeometry {
val quad = PlaneGeometry(
width.toDouble(),
height.toDouble(),
widthSegments = 1.0,
heightSegments = 1.0
)
quad.faceVertexUvs = arrayOf(
arrayOf(
arrayOf(Vector2(0.0, 0.0), Vector2(0.0, 1.0), Vector2(1.0, 0.0)),
arrayOf(Vector2(0.0, 1.0), Vector2(1.0, 1.0), Vector2(1.0, 0.0)),
)
)
quad.translate(x + width / 2, y + height / 2, -5.0)
return quad
}
}

View File

@ -0,0 +1,145 @@
package world.phantasmal.web.viewer.rendering
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.set
import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.externals.three.*
import world.phantasmal.webui.obj
import kotlin.math.roundToInt
fun xvrTextureToThree(xvr: XvrTexture, filter: TextureFilter = LinearFilter): Texture {
val format: CompressedPixelFormat
val dataSize: Int
when (xvr.format.second) {
6 -> {
format = RGBA_S3TC_DXT1_Format
dataSize = (xvr.width * xvr.height) / 2
}
7 -> {
format = RGBA_S3TC_DXT3_Format
dataSize = xvr.width * xvr.height
}
else -> error("Format ${xvr.format.first}, ${xvr.format.second} not supported.")
}
val texture = CompressedTexture(
arrayOf(obj {
data = Uint8Array(xvr.data.arrayBuffer, 0, dataSize)
width = xvr.width
height = xvr.height
}),
xvr.width,
xvr.height,
format,
wrapS = MirroredRepeatWrapping,
wrapT = MirroredRepeatWrapping,
magFilter = filter,
minFilter = filter,
)
texture.needsUpdate = true
return texture
}
private fun xvrTextureToUint8Array(xvr: XvrTexture): Uint8Array {
val dataSize = when (xvr.format.second) {
6 -> (xvr.width * xvr.height) / 2
7 -> xvr.width * xvr.height
else -> error("Format ${xvr.format.first}, ${xvr.format.second} not supported.")
}
val cursor = xvr.data.cursor(size = dataSize)
val image = Uint8Array(xvr.width * xvr.height * 4)
val stride = 4 * xvr.width
var i = 0
while (cursor.hasBytesLeft(8)) {
// Each block of 4 x 4 pixels is compressed to 8 bytes.
val c0 = cursor.uShort().toInt() // Color 0
val c1 = cursor.uShort().toInt() // Color 1
val codes = cursor.int() // A 2-bit code per pixel.
// Extract color components and normalize them to the range [0, 1].
val c0r = (c0 ushr 11) / 31.0
val c0g = ((c0 ushr 5) and 0x3F) / 63.0
val c0b = (c0 and 0x1F) / 31.0
val c1r = (c1 ushr 11) / 31.0
val c1g = ((c1 ushr 5) and 0x3F) / 63.0
val c1b = (c1 and 0x1F) / 31.0
// Loop over the codes.
for (j in 0 until 16) {
val shift = 2 * (16 - j - 1)
val r: Double
val g: Double
val b: Double
val a: Double
when ((codes ushr shift) and 0b11) {
0 -> {
r = c0r
g = c0g
b = c0b
a = 1.0
}
1 -> {
r = c1r
g = c1g
b = c1b
a = 1.0
}
2 -> {
if (c0 > c1) {
r = (2 * c0r + c1r) / 3
g = (2 * c0g + c1g) / 3
b = (2 * c0b + c1b) / 3
a = 1.0
} else {
r = (c0r + c1r) / 2
g = (c0g + c1g) / 2
b = (c0b + c1b) / 2
a = 1.0
}
}
3 -> {
if (c0 > c1) {
r = (c0r + 2 * c1r) / 3
g = (c0g + 2 * c1g) / 3
b = (c0b + 2 * c1b) / 3
a = 1.0
} else {
r = 0.0
g = 0.0
b = 0.0
a = 0.0
}
}
// Unreachable case.
else -> error("Invalid code.")
}
// Block-relative pixel coordinates.
val blockX = 3 - j % 4
val blockY = 3 - j / 4
// Offset into the image array.
val offset = i + (4 * blockX + blockY * stride)
image[offset] = (r * 255).roundToInt().toByte()
image[offset + 1] = (g * 255).roundToInt().toByte()
image[offset + 2] = (b * 255).roundToInt().toByte()
image[offset + 3] = (a * 255).roundToInt().toByte()
}
// Jump ahead 4 pixels.
i += 16
if (i % stride == 0) {
// Jump ahead 4 rows.
i += 3 * stride
}
}
return image
}

View File

@ -2,16 +2,25 @@ package world.phantasmal.web.viewer.store
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.ninja.NinjaObject import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.mutableListVal
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.webui.stores.Store import world.phantasmal.webui.stores.Store
class ViewerStore(scope: CoroutineScope) : Store(scope) { class ViewerStore(scope: CoroutineScope) : Store(scope) {
private val _currentNinjaObject = mutableVal<NinjaObject<*>?>(null) private val _currentNinjaObject = mutableVal<NinjaObject<*>?>(null)
private val _currentTextures = mutableListVal<XvrTexture>(mutableListOf())
val currentNinjaObject: Val<NinjaObject<*>?> = _currentNinjaObject val currentNinjaObject: Val<NinjaObject<*>?> = _currentNinjaObject
val currentTextures: ListVal<XvrTexture> = _currentTextures
fun setCurrentNinjaObject(ninjaObject: NinjaObject<*>?) { fun setCurrentNinjaObject(ninjaObject: NinjaObject<*>?) {
_currentNinjaObject.value = ninjaObject _currentNinjaObject.value = ninjaObject
} }
fun setCurrentTextures(textures: List<XvrTexture>) {
_currentTextures.value = textures
}
} }

View File

@ -1,11 +1,11 @@
package world.phantasmal.web.viewer.widgets package world.phantasmal.web.viewer.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.web.core.rendering.Renderer import world.phantasmal.web.viewer.controller.ViewerController
import world.phantasmal.web.core.widgets.RendererWidget import world.phantasmal.web.viewer.controller.ViewerTab
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.TabContainer
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
/** /**
@ -13,20 +13,22 @@ import world.phantasmal.webui.widgets.Widget
*/ */
class ViewerWidget( class ViewerWidget(
scope: CoroutineScope, scope: CoroutineScope,
private val ctrl: ViewerController,
private val createToolbar: (CoroutineScope) -> Widget, private val createToolbar: (CoroutineScope) -> Widget,
private val canvas: HTMLCanvasElement, private val createMeshWidget: (CoroutineScope) -> Widget,
private val renderer: Renderer, private val createTextureWidget: (CoroutineScope) -> Widget,
) : Widget(scope) { ) : Widget(scope) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-viewer-viewer" className = "pw-viewer-viewer"
addChild(createToolbar(scope)) addChild(createToolbar(scope))
div { addChild(TabContainer(scope, ctrl = ctrl, createWidget = { scope, tab ->
className = "pw-viewer-viewer-container" when (tab) {
ViewerTab.Mesh -> createMeshWidget(scope)
addChild(RendererWidget(scope, canvas, renderer)) ViewerTab.Texture -> createTextureWidget(scope)
} }
}))
} }
companion object { companion object {
@ -38,10 +40,8 @@ class ViewerWidget(
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.pw-viewer-viewer-container { .pw-viewer-viewer > .pw-tab-container {
flex-grow: 1; flex-grow: 1;
display: flex;
flex-direction: row;
overflow: hidden; overflow: hidden;
} }
""".trimIndent()) """.trimIndent())