An experimental retained-mode 3D scene API for Kotlin and Compose Multiplatform.
The project deliberately separates the public scene model from the renderer. Compose describes the desired scene, the reconciler emits a small set of backend commands, and GPU resources remain owned by a renderer implementation rather than by recomposition.
Early architecture prototype with working Filament primitive and GLB rendering on Android, Desktop and iOS/Metal, plus an independent WebGL2 renderer for Web/Wasm. A stable public release is not available yet.
Current release coordinates: io.github.aleksandrkdev:*:0.1.0-alpha02.
Source repository: AleksandrKdev/compose-scene-3d.
scene-core: immutable scene descriptions, retained scene reconciliation and renderer commands.scene-compose: a Compose DSL that produces scene descriptions without running a frame loop through Compose state.renderer-filament: a retained adapter over Filament KMP; Filament types stay private to the backend implementation. Models with the same cache key share one imported GPU asset while retaining independent instances and transforms. It provides orbit/pan/zoom interaction and maps Filament picking results back to stableNodeKeyvalues. Box, sphere, plane and cylinder primitives use backend-neutral PBR material parameters.renderer-web: an independent WebGL2 Wasm renderer for primitives, indexed custom meshes, nested transforms and the shared orbit/pan/zoom camera. It uses GPU vertex/index buffers and a depth buffer. Base-color textures load asynchronously from resources, URLs or encoded bytes and are cached byTextureAssetKey. It loads binary (.glb) and JSON (.gltf) glTF 2.0 models; its PBR shader supports directional, point and spot lighting plus directional and spot PCF shadow maps. Camera projection runs in the vertex shader. Per-mesh WebGL buffers persist across renders, are shared by all active passes and skip uploads while world-space geometry is unchanged. CPU mesh batches are likewise reused until the scene or asynchronously loaded model set changes. Imported glTF materials honor base-color factors,OPAQUE/MASK/BLEND,alphaCutoffanddoubleSided.renderer-testkit: an internal backend-neutral conformance harness for retained commands, lifecycle behavior and capability declarations. New renderers must pass the same contract.samples/android-app,samples/desktop-app,samples/ios-appandsamples/web-app: interactive platform samples.
- Public API lives in
commonMain. - Stable node keys define identity; list position does not.
- Reconciliation changes retained nodes in place where possible.
- Backend capabilities are explicit instead of pretending every GPU API is identical.
- Renderer handles and native resources never leak into application state.
- Animation and per-frame transforms belong to the renderer/frame loop, not recomposition.
val controller = rememberSceneController(renderer)
val assemblyRotation = Quaternion(0f, sin(angle / 2f), 0f, cos(angle / 2f))
Scene3D(controller) {
group(
key = "product-assembly",
transform = Transform(rotation = assemblyRotation),
) {
sphere(
key = "accent",
material = PbrMaterial(
baseColor = Color3D(0.9f, 0.55f, 0.12f),
metallic = 1f,
roughness = 0.2f,
),
transform = Transform(translation = Vec3(-1.5f, 0f, 0f)),
)
model(
key = "product",
source = ModelSource.Resource("files/product.glb"),
transform = Transform(scale = Vec3(0.5f, 0.5f, 0.5f)),
)
}
directionalLight(key = "sun", intensity = 50_000f)
pointLight(
key = "warm-fill",
intensity = 1_500f,
color = Color3D.rgb(255, 170, 100),
transform = Transform(translation = Vec3(-2f, 2f, 2f)),
)
}group { ... } creates a real scene-graph node. Child transforms are local to their parent;
translation, quaternion rotation and scale are inherited through any number of nested groups.
Node keys remain unique across the entire tree, and picking still reports the leaf node key.
Custom indexed triangle meshes use portable CPU-side arrays. Positions and normals contain three
floats per vertex; UVs contain two and are required for TexturedMaterial. Indices use
counter-clockwise triangle winding when viewed from the front:
val triangle = Geometry3D(
positions = floatArrayOf(-1f, 0f, 0f, 1f, 0f, 0f, 0f, 1f, 0f),
indices = intArrayOf(0, 1, 2),
normals = floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 0f, 0f, 1f),
uvs = floatArrayOf(0f, 0f, 1f, 0f, 0.5f, 1f),
)
mesh(
key = "triangle",
geometry = triangle,
material = PbrMaterial(baseColor = Color3D.Magenta),
)The Filament backend calculates the bounding box and tangent-frame quaternions, uploads immutable vertex/index buffers, participates in scene hierarchy and picking, and releases native resources when geometry leaves the composition.
Color3D distinguishes sRGB input from linear-sRGB values and supports RGB/RGBA/ARGB factories
and named colors. Primitive materials can be PbrMaterial, UnlitMaterial, EmissiveMaterial,
TexturedMaterial or TransparentMaterial.
sphere(
key = "glass",
material = TransparentMaterial(
color = Color3D(0.2f, 0.65f, 1f, alpha = 0.35f),
roughness = 0.12f,
),
)Transparent colors are converted to linear-sRGB and premultiplied by alpha before reaching the
shader. The bundled blended material is compiled for all Filament backends with the exact matc
version used by the runtime dependency.
Texture data can come from common resources, URLs or in-memory bytes without exposing a Filament type to shared code:
plane(
key = "floor",
material = TexturedMaterial(
baseColorTexture = TextureSource.Resource("files/floor-albedo.png"),
normalTexture = TextureSource.Resource("files/floor-normal.png"),
metallicRoughnessTexture = TextureSource.Resource("files/floor-mr.png"),
emissiveTexture = TextureSource.Resource("files/floor-emissive.png"),
ambientOcclusionTexture = TextureSource.Resource("files/floor-ao.png"),
normalScale = 1f,
emissiveColor = Color3D.White,
emissiveIntensity = 0.5f,
ambientOcclusionStrength = 1f,
roughness = 0.9f,
),
)Metallic-roughness maps follow the glTF convention: roughness is read from the green channel and metallic from blue. Albedo and emissive maps decode as sRGB; normal, metallic-roughness and AO maps remain linear data. Missing optional maps do not consume placeholder application assets.
The WebGL2 backend implements direct-light metallic/roughness PBR with a GGX specular BRDF,
tone mapping and perspective-correct base-color texture sampling. It consumes the first
DirectionalLightNode in the scene and supports all three TextureSource variants, generates
mipmaps after browser image decoding, and redraws the viewport when asynchronous loading completes.
Normal maps use a derivative-based tangent frame, so custom geometry does not need explicit tangent
attributes. Metallic-roughness (glTF green/blue channels), emissive and AO maps are supported on
Web as well as Filament. Web lighting supports one directional light plus up to four point and four
spot lights. Positional lights honor nested transforms, distance falloff and spot cone angles.
Web models accept the same ModelSource.Resource, Url and Bytes variants. The current loader
supports GLB 2.0 JSON/BIN chunks, triangle primitives, indexed or non-indexed accessors, interleaved
buffer views, node TRS/matrices and embedded base-color images. Resource and URL sources may also
be JSON .gltf files with one external or data-URI buffer and external images. Loader failures can
be observed through WebRenderer(onModelError = ..., onTextureError = ...). Multiple buffers,
sparse accessors, morph targets, skins and animation are intentionally rejected or ignored for now.
For Filament, TextureSource.Bytes works with the default renderer. Resource and URL sources use
an application-provided TextureByteLoader, following the same pattern as ModelByteLoader.
Preprocessed KTX1 cubemaps provide specular reflections, diffuse spherical-harmonic irradiance and an optional visible skybox:
val environment = EnvironmentMap(
reflections = TextureSource.Resource("files/studio_ibl.ktx"),
skybox = TextureSource.Resource("files/studio_skybox.ktx"),
intensity = 18_000f,
)
FilamentViewport(renderer = renderer, environment = environment)Generate both files offline with Filament cmgen -f ktx -x output environment.hdr. Runtime HDR
conversion is intentionally excluded: preprocessing produces smaller assets and deterministic
results on Android, Desktop and iOS.
Shadow participation is configured independently from the view-wide technique and the light's shadow map:
FilamentViewport(
renderer = renderer,
shadows = ShadowTechnique3D.Pcf,
)
Scene3D(controller) {
sphere(key = "caster", castShadows = true, receiveShadows = true)
plane(key = "ground", castShadows = false, receiveShadows = true)
directionalLight(
key = "sun",
intensity = 100_000f,
shadow = ShadowMap3D(
mapSize = 2048,
cascades = 2,
contactShadows = true,
bulbRadius = 0.05f,
),
)
}Pcf, Pcfd, Vsm, Dpcf and Pcss are portable view techniques. Directional and spot lights
can own a ShadowMap3D; point-light shadows are deliberately unsupported because Filament does
not implement the required cubemap shadow maps. Passing shadows = null disables the view-wide
shadow pass.
Dpcf and Pcss additionally require compatible VSM receiver variants in every material,
including materials embedded in loaded GLB assets. Use Pcf as the portable default when asset
provenance is unknown.
On Web, the first shadow-enabled directional and spot lights each get a depth map with 3x3 PCF.
The directional map uses an orthographic projection and the spot map follows its position,
direction, outer cone and falloff with a perspective projection. mapSize is honored up to 2048,
and node-level castShadows/receiveShadows flags are supported. The directional frustum fits the
current world-space geometry with padding, texel-grid stabilization and polygon-offset acne
protection. Cascades, contact shadows and the other filtering techniques still fall back to PCF.
- Add Web glTF sampler settings and texture-coordinate set selection.
- Continue glTF feature coverage and optimize Web GPU resource submission.
- Stabilize the public API based on cross-backend experience.
Android:
./gradlew :samples:android-app:assembleDebugDesktop requires JDK 22+ because Filament uses Project Panama FFM bindings:
./gradlew :samples:desktop-app:runiOS: open samples/ios-app/iosApp.xcodeproj, choose an arm64 simulator and run the iosApp scheme.
Xcode builds and embeds the Kotlin framework automatically.
Web/Wasm:
./gradlew :samples:web-app:wasmJsBrowserDevelopmentRunThe command starts the webpack development server and opens the sample in a browser. Drag to orbit, use the mouse wheel to zoom, and use a secondary-button drag to pan.
The macOS GitHub Actions workflow runs common/JVM tests, builds the Android sample, compiles the Desktop sample, links simulator and device iOS frameworks, and builds the complete SwiftUI host. The workflow deliberately targets arm64 because Filament KMP publishes an arm64 iOS Simulator artifact.
Publish all library modules to a repository under build/maven-alpha:
./gradlew publishAllPublicationsToLocalAlphaRepositoryConsumer projects can then use:
repositories {
maven { url = uri("/path/to/ComposeScene3D/build/maven-alpha") }
}
dependencies {
implementation("io.github.aleksandrkdev:scene-compose:0.1.0-alpha03-SNAPSHOT")
implementation("io.github.aleksandrkdev:renderer-filament:0.1.0-alpha03-SNAPSHOT")
}The published modules keep JVM and KLIB ABI baselines under their api/ directories.
./gradlew checkKotlinAbi detects accidental public API changes; intentionally accepted changes
are recorded with ./gradlew updateKotlinAbi after reviewing the diff.
The Publish alpha workflow publishes every KMP variant to this repository's GitHub Packages
registry on manual dispatch or a v* tag. It uses the workflow GITHUB_TOKEN. After publication,
the workflow compiles an independent consumer project from verification/published-consumer,
resolving the modules back from GitHub Packages rather than from this build.
GitHub Packages requires authentication when consuming Maven packages. Add the repository and use
a GitHub personal access token with read:packages permission:
repositories {
maven {
url = uri("https://maven.pkg.github.com/AleksandrKdev/compose-scene-3d")
credentials {
username = providers.gradleProperty("gpr.user").orNull
?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.key").orNull
?: System.getenv("GITHUB_TOKEN")
}
}
mavenCentral()
google()
}
dependencies {
implementation("dev.composescene3d:scene-compose:0.1.0-alpha01")
implementation("dev.composescene3d:renderer-filament:0.1.0-alpha01")
}Keep credentials outside the project, for example in ~/.gradle/gradle.properties:
gpr.user=YOUR_GITHUB_USERNAME
gpr.key=YOUR_PERSONAL_ACCESS_TOKENVersion 0.1.0-alpha02 is available from Maven Central, the primary public repository. Consumers
only need mavenCentral() and do not need GitHub credentials:
repositories {
mavenCentral()
}
dependencies {
implementation("io.github.aleksandrkdev:scene-compose:0.1.0-alpha02")
implementation("io.github.aleksandrkdev:renderer-filament:0.1.0-alpha02")
}Maintainers publish tags through the Publish Maven Central workflow. It expects Central Portal
user-token secrets MAVEN_CENTRAL_USERNAME and MAVEN_CENTRAL_PASSWORD, plus the armored private
key SIGNING_KEY and its SIGNING_PASSWORD.
For Android-only development Android Studio may use its bundled JDK 21. Do not configure a project-wide Gradle daemon JVM criterion for Java 22: that can prevent initial sync before Gradle's toolchain resolver is loaded. Select an installed JDK 22 as the Gradle JDK only when running the Desktop sample.
Apache-2.0.