Compare commits

...
11 changed files with 351 additions and 6 deletions
@@ -75,4 +75,4 @@ object ParticleMaterials {
override fun toString() = "OPAQUE"
}
}
}
@@ -33,6 +33,7 @@ import com.cobblemon.mod.common.client.gui.tmmachine.TMMachineScreen
import com.cobblemon.mod.common.client.particle.BedrockParticleOptionsRepository
import com.cobblemon.mod.common.client.render.ClientPlayerIcon
import com.cobblemon.mod.common.client.render.DeferredRenderer
import com.cobblemon.mod.common.client.render.SnowstormShaderBypassRenderer
import com.cobblemon.mod.common.client.render.block.*
import com.cobblemon.mod.common.client.render.boat.CobblemonBoatRenderer
import com.cobblemon.mod.common.client.render.color.AprijuiceItemColorProvider
@@ -140,6 +141,9 @@ object CobblemonClient {
PlatformEvents.CLIENT_PLAYER_LOGIN.subscribe { onLogin() }
PlatformEvents.CLIENT_PLAYER_LOGOUT.subscribe { onLogout() }
// Renders snowstorm particles with a vanilla-identical look when a shaderpack is active.
SnowstormShaderBypassRenderer.register()
this.registerBlockEntityRenderers()
registerBlockRenderTypes()
registerColors()
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.client.render
import java.lang.reflect.Method
/**
* Detects whether a shaderpack is currently active, via Iris (Fabric/NeoForge) or OptiFine.
*
* Every reference here is reflective and fully guarded: Iris/OptiFine are optional and may be
* absent at runtime, so we never hard-reference their classes. If neither is present (or their
* APIs ever change shape) the lookups fail silently during class init and [shadersActive] simply
* returns `false`. This is the loader-safe contract the rest of the particle code relies on — it
* must work identically on Fabric and NeoForge and when no shader mod is installed at all.
*
* The reflective handles are resolved once; only the cheap boolean accessor is invoked per call.
* Particle render types call this at most a handful of times per frame (once per [begin]), so the
* cost is negligible.
*/
object ShaderCompat {
// Iris: net.irisshaders.iris.api.v0.IrisApi.getInstance().isShaderPackInUse()
// getInstance() returns a stable singleton, so it is safe to cache.
private val irisInstance: Any?
private val irisIsShaderPackInUse: Method?
// OptiFine: net.optifine.Config.isShaders() (static)
private val optifineIsShaders: Method?
init {
var irisInst: Any? = null
var irisMethod: Method? = null
try {
val irisApi = Class.forName("net.irisshaders.iris.api.v0.IrisApi")
irisInst = irisApi.getMethod("getInstance").invoke(null)
irisMethod = irisApi.getMethod("isShaderPackInUse")
} catch (ignored: Throwable) {
irisInst = null
irisMethod = null
}
irisInstance = irisInst
irisIsShaderPackInUse = irisMethod
var optifineMethod: Method? = null
try {
optifineMethod = Class.forName("net.optifine.Config").getMethod("isShaders")
} catch (ignored: Throwable) {
optifineMethod = null
}
optifineIsShaders = optifineMethod
}
/**
* `true` when Iris or OptiFine reports an active (compiled and enabled) shaderpack. Never throws;
* any reflective failure is treated as "no shaders".
*/
fun shadersActive(): Boolean {
val instance = irisInstance
val irisMethod = irisIsShaderPackInUse
if (instance != null && irisMethod != null) {
try {
if (irisMethod.invoke(instance) == true) return true
} catch (ignored: Throwable) {}
}
val optifineMethod = optifineIsShaders
if (optifineMethod != null) {
try {
if (optifineMethod.invoke(null) == true) return true
} catch (ignored: Throwable) {}
}
return false
}
}
@@ -54,6 +54,15 @@ class SnowstormParticle(
) : Particle(world, x, y, z) {
companion object {
const val MAXIMUM_DISTANCE_CHANGE_PER_TICK_FOR_FRICTION = 0.005
/**
* Every live (non-removed) snowstorm particle, in spawn order. When a shaderpack is active we
* pull these out of the vanilla [net.minecraft.client.particle.ParticleEngine] (see
* [getRenderType] returning [NO_RENDER]) and redraw them post-composite via
* [SnowstormShaderBypassRenderer], so the pack's particle program can't alter their look.
* Insertion-ordered; particles use identity equality so a LinkedHashSet is safe.
*/
val activeParticles: MutableSet<SnowstormParticle> = LinkedHashSet()
}
val sprite = getSpriteFromAtlas()
@@ -127,6 +136,7 @@ class SnowstormParticle(
}
}
storm.effect.particle.creationEvents.forEach { it.trigger(storm, this) }
activeParticles.add(this)
}
override fun render(vertexConsumer: VertexConsumer, camera: Camera, tickDelta: Float) {
@@ -496,10 +506,15 @@ class SnowstormParticle(
runtime.environment.variable.setDirectly("particle_lifetime", DoubleValue(lifetime / 20.0))
}
override fun getRenderType() = particleTextureSheet
// Under an active shaderpack we suppress the vanilla ParticleEngine draw (it would be routed
// through the pack's translucent particle program and mangled differently per pack) and instead
// redraw post-composite in SnowstormShaderBypassRenderer for a vanilla-identical, pack-agnostic
// look. With no shaders this is the normal material sheet, so the majority path is unchanged.
override fun getRenderType() = if (Cobblemon.config.shaderParticleBypass && ShaderCompat.shadersActive()) NO_RENDER else particleTextureSheet
override fun remove() {
super.remove()
storm.particles.remove(this)
activeParticles.remove(this)
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.client.render
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.platform.events.PlatformEvents
import com.cobblemon.mod.common.platform.events.RenderEvent
import com.mojang.blaze3d.systems.RenderSystem
import com.mojang.blaze3d.vertex.BufferUploader
import com.mojang.blaze3d.vertex.Tesselator
import net.minecraft.client.Minecraft
import org.joml.Matrix4f
/**
* Renders Cobblemon's snowstorm particles with a vanilla-identical, pack-agnostic look while a
* shaderpack (Iris/OptiFine) is active.
*
* ## Why this exists
* Iris/OptiFine replace the vanilla particle shader with the shaderpack's translucent-particle
* program for the duration of the world pass, and every pack composites that program differently —
* which made our particles render too solid (Complementary), invisible (Photon), or still-darkening
* (Bliss) no matter how we tuned blend/alpha inside the vanilla [net.minecraft.client.particle.ParticleEngine].
* The pack has final authority there; we cannot win.
*
* The one place a shaderpack does NOT intercept a draw is **after** `LevelRenderer.renderLevel`
* returns: Iris only overrides shaders while `isRenderingWorld` is true, and it flips that off during
* its final composite (injected at the world-render RETURN). So we:
* 1. suppress the vanilla draw of these particles while shaders are active
* ([SnowstormParticle.getRenderType] returns NO_RENDER), and
* 2. redraw them here at [RenderEvent.Stage.POST_LEVEL] (post-composite) with the exact vanilla GL
* state from [com.cobblemon.mod.common.api.snowstorm.ParticleMaterials] and the unchanged
* [SnowstormParticle.render] quad math.
*
* The main framebuffer's depth attachment still holds the full (opaque + translucent) world depth at
* this point — Iris reuses Minecraft's main depth texture and the composite only copies colour — so
* particles still occlude correctly behind terrain AND water/glass. They simply don't receive the
* pack's lighting/fog/bloom, which is exactly the intended "looks like vanilla" result.
*
* When no shaderpack is active this renderer does nothing and particles draw normally through the
* ParticleEngine, so the majority (no-shader) path is completely unchanged.
*
* @author Cobblemon
*/
object SnowstormShaderBypassRenderer {
// The world's camera view matrix for the current frame, captured at the late in-level stage
// where Fabric/NeoForge still have the camera transform applied. POST_LEVEL fires after that
// state is unwound, but the bypass draw still needs it because SnowstormParticle.render emits
// camera-relative vertices just like vanilla particles do.
private val modelViewMatrix = Matrix4f()
private var captured = false
fun register() {
PlatformEvents.RENDER.subscribe { event ->
when (event.stage) {
RenderEvent.Stage.LAST -> {
modelViewMatrix.set(RenderSystem.getModelViewMatrix())
captured = true
}
RenderEvent.Stage.POST_LEVEL -> render(event)
else -> {}
}
}
}
private fun render(event: RenderEvent) {
if (!Cobblemon.config.shaderParticleBypass || !ShaderCompat.shadersActive() || !captured) {
captured = false
return
}
val visible = SnowstormParticle.activeParticles.filter { !it.invisible }
if (visible.isEmpty()) {
captured = false
return
}
val minecraft = Minecraft.getInstance()
val camera = event.camera
val partialTicks = event.tickCounter.getGameTimeDeltaPartialTick(false)
val lightTexture = minecraft.gameRenderer.lightTexture()
val tesselator = Tesselator.getInstance()
// Re-establish the world's camera view so the camera-relative quads built by render() land
// in the right place; projection is still the world's 3D projection at this point in the frame.
val modelViewStack = RenderSystem.getModelViewStack()
modelViewStack.pushMatrix()
modelViewStack.set(modelViewMatrix)
RenderSystem.applyModelViewMatrix()
try {
lightTexture.turnOnLightLayer()
// Group by the particle's own render type (ParticleMaterials.ALPHA/ADD/BLEND/OPAQUE). Calling
// its begin() sets the IDENTICAL GL state vanilla would use, so the look matches no-shaders.
for ((renderType, particles) in visible.groupBy { it.particleTextureSheet }) {
val buffer = renderType.begin(tesselator, minecraft.textureManager) ?: continue
for (particle in particles) {
particle.render(buffer, camera, partialTicks)
}
val mesh = buffer.build() ?: continue
BufferUploader.drawWithShader(mesh)
}
} finally {
lightTexture.turnOffLightLayer()
modelViewStack.popMatrix()
RenderSystem.applyModelViewMatrix()
captured = false
}
// Restore neutral GL state so nothing downstream (hand already drawn, GUI next) is affected.
RenderSystem.depthMask(true)
RenderSystem.disableBlend()
RenderSystem.defaultBlendFunc()
RenderSystem.enableCull()
}
}
@@ -331,6 +331,9 @@ class CobblemonConfig {
@CobblemonConfigField(Category.Interface, lang = "animate_battle_tiles", CLIENT)
var animateBattleTiles = false
@CobblemonConfigField(Category.Interface, lang = "shader_particle_bypass", CLIENT)
var shaderParticleBypass = true
// Disabled as non-tenable for 1.7 until all the posers catch up with this property.
// @CobblemonConfigField(Category.Riding, lang = "third_person_view_bobbing", CLIENT)
var thirdPersonViewBobbing = true
@@ -32,6 +32,22 @@ data class RenderEvent(
/** Represents when a layer is rendered by [LevelRenderer.renderLevel]. Ordinal corresponds with its render order. */
enum class Stage {
TRANSLUCENT
TRANSLUCENT,
/**
* Fired after the world framebuffer writes are complete but before the level renderer tears
* down its camera view transform. Direct draws at [POST_LEVEL] can reuse the matrix captured
* here when they need to render after shaderpack composition.
*/
LAST,
/**
* Fired after [LevelRenderer.renderLevel] has fully finished — on a shaderpack, this is after
* Iris/OptiFine has composited the world to the main framebuffer and restored vanilla shaders.
* Draws here are NOT intercepted by the shaderpack, so they render with vanilla shaders while
* still depth-testing against the full (opaque + translucent) world depth. Used to render
* Cobblemon's snowstorm particles with a vanilla-identical look under shaders.
*/
POST_LEVEL
}
}
}
@@ -2539,6 +2539,8 @@
"cobblemon.config.ui.summary_pokemon_follow_cursor.tooltip": "Whether or not Pokémon should follow the mouse cursor with their heads in the Summary screen.",
"cobblemon.config.ui.animate_battle_tiles": "Animate Battle Tiles",
"cobblemon.config.ui.animate_battle_tiles.tooltip": "Whether or not the battle portraits of Pokemon should be animated.",
"cobblemon.config.ui.shader_particle_bypass": "Shader Particle Bypass",
"cobblemon.config.ui.shader_particle_bypass.tooltip": "When enabled, Cobblemon particles are redrawn after shaderpack composition so Iris and OptiFine shaderpacks do not alter their appearance.",
"cobblemon.config.ui.party_portrait_animations": "Party Portrait Animations",
"cobblemon.config.ui.party_portrait_animations.never_animate": "Never Animate",
"cobblemon.config.ui.party_portrait_animations.never_animate.tooltip": "No portraits animate",
+70
View File
@@ -8,6 +8,8 @@
*
*/
import java.net.URI
configurations.all {
resolutionStrategy {
force(libs.fabric.loader)
@@ -48,6 +50,23 @@ dependencies {
isTransitive = false
}
modLocalRuntime(libs.fabric.debugutils)
// Shader-compat testing (DEV RUNTIME ONLY — never shipped, never a real dependency).
// Lets `runClient` load Iris + Sodium so shaderpacks actually run, which is required to
// exercise ParticleMaterials' depthMask branch (see ShaderCompat). isTransitive = false so
// these don't drag in a second copy of Fabric API / FLK (already provided above). If you bump
// these, keep the Iris<->Sodium pairing valid (Iris 1.8.x needs Sodium 0.6.x for MC 1.21.1).
modLocalRuntime("maven.modrinth:iris:1.8.8+1.21.1-fabric") { isTransitive = false }
modLocalRuntime("maven.modrinth:sodium:mc1.21.1-0.6.13-fabric") { isTransitive = false }
// Iris bundles these as jar-in-jar in production, but loom doesn't add a mod's nested *libraries*
// to the dev classpath, so the dev client crashes with NoClassDefFoundError (e.g. org.anarres.cpp
// / glsl-transformer) when Iris loads a shaderpack. Supply them as plain dev-runtime libs.
// Versions are taken verbatim from iris-1.8.8's META-INF/jars — keep them in lockstep if Iris is
// bumped. localRuntime = dev runtime only, not remapped, not exported to the published jar.
localRuntime("org.anarres:jcpp:1.4.14")
localRuntime("io.github.douira:glsl-transformer:2.0.1")
localRuntime("org.antlr:antlr4-runtime:4.13.1")
modImplementation(libs.fabric.loader)
modApi(libs.fabric.api)
modApi(libs.bundles.fabric)
@@ -110,6 +129,57 @@ publishing {
}
tasks {
// Downloads the Complementary Unbound shaderpack into the dev client's shaderpacks/ folder and
// enables Iris by default, so `runClient` comes up shader-ready with zero manual setup. Both
// writes are idempotent: existing files are left untouched, so your in-game shader choice and
// any pack you swap in are preserved across runs. Run dir is "runClient" (see platform-conventions).
val setupShaderTesting by registering {
group = "cobblemon"
description = "Provisions the test shaderpacks + enables Iris for the dev runClient."
// The four Iris packs we want particles to look correct under. Swap between them in-game
// (Video Settings -> Shader Packs) to A/B the premultiplied-alpha fix in ParticleMaterials.
// <filename> to <direct Modrinth CDN url>.
//
// VERSIONS ARE PINNED FOR MC 1.21.1. The newest releases of these packs reference the
// BIOME_PALE_GARDEN uniform (Pale Garden is a 1.21.4 biome), which Iris cannot resolve on
// 1.21.1 -> hard crash building the render pipeline ("Unknown variable: BIOME_PALE_GARDEN").
// Each pin below was confirmed Pale-Garden-free by grepping the archive. If this project ever
// targets >= 1.21.4, bump these to the latest versions.
val packs = mapOf(
"ComplementaryUnbound_r5.3.zip" to "https://cdn.modrinth.com/data/R6NEzAwj/versions/CVu3AGAd/ComplementaryUnbound_r5.3.zip",
"ComplementaryReimagined_r5.3.zip" to "https://cdn.modrinth.com/data/HVnmMxH1/versions/71Hn3myZ/ComplementaryReimagined_r5.3.zip",
"Bliss_v2.1.2_(Chocapic13_Shaders_edit).zip" to "https://cdn.modrinth.com/data/ZvMtQlho/versions/kC2Y8q1P/Bliss_v2.1.2_%28Chocapic13_Shaders_edit%29.zip",
"photon_v1.1.zip" to "https://cdn.modrinth.com/data/lLqFfGNs/versions/6FdrBtKp/photon_v1.1.zip"
)
val shaderpacksDir = layout.projectDirectory.dir("runClient/shaderpacks")
val irisProps = layout.projectDirectory.file("runClient/config/iris.properties")
// Default the in-game selection to Complementary Unbound (the originally-reported pack); idempotent.
val defaultPack = "ComplementaryUnbound_r5.3.zip"
outputs.dir(shaderpacksDir)
doLast {
packs.forEach { (filename, url) ->
val zip = shaderpacksDir.file(filename).asFile
if (!zip.exists()) {
zip.parentFile.mkdirs()
logger.lifecycle("Downloading shaderpack -> ${zip.relativeTo(rootProject.projectDir)}")
URI(url).toURL().openStream().use { input ->
zip.outputStream().use { output -> input.copyTo(output) }
}
}
}
val props = irisProps.asFile
if (!props.exists()) {
props.parentFile.mkdirs()
props.writeText("enableShaders=true\nshaderPack=$defaultPack\n")
logger.lifecycle("Wrote ${props.relativeTo(rootProject.projectDir)} (Iris auto-enabled with $defaultPack).")
}
}
}
named("runClient") {
dependsOn(setupShaderTesting)
}
// The AW file is needed in :fabric project resources when the game is run.
val copyAccessWidener by registering(Copy::class) {
from(loom.accessWidenerPath)
@@ -171,6 +171,35 @@ class CobblemonFabricClient: ClientModInitializer, CobblemonClientImplementation
)
}
// Capture the final in-level camera transform before Fabric unwinds render state.
WorldRenderEvents.LAST.register { context -> PlatformEvents.RENDER.post(
RenderEvent(
stage = RenderEvent.Stage.LAST,
levelRenderer = context.worldRenderer(),
poseStack = context.matrixStack() ?: PoseStack(),
modelViewMatrix = context.positionMatrix(),
projectionMatrix = context.projectionMatrix(),
tickCounter = context.tickCounter(),
camera = context.camera()
)
)
}
// Fires after the level (and any shaderpack composite) has finished — the only point where a
// draw is NOT intercepted by Iris/OptiFine. Used by SnowstormShaderBypassRenderer.
WorldRenderEvents.END.register { context -> PlatformEvents.RENDER.post(
RenderEvent(
stage = RenderEvent.Stage.POST_LEVEL,
levelRenderer = context.worldRenderer(),
poseStack = context.matrixStack() ?: PoseStack(),
modelViewMatrix = context.positionMatrix(),
projectionMatrix = context.projectionMatrix(),
tickCounter = context.tickCounter(),
camera = context.camera()
)
)
}
CobblemonModelPredicateRegistry.registerPredicates()
}
@@ -201,4 +230,4 @@ class CobblemonFabricClient: ClientModInitializer, CobblemonClientImplementation
override fun <T : Entity> registerEntityRenderer(type: EntityType<out T>, factory: EntityRendererProvider<T>) {
EntityRendererRegistry.register(type, factory)
}
}
}
@@ -74,6 +74,8 @@ object NeoForgeClientPlatformEventHandler {
fun onRenderLevelStageEvent(event: RenderLevelStageEvent) {
val stage = when (event.stage) {
RenderLevelStageEvent.Stage.AFTER_PARTICLES -> RenderEvent.Stage.TRANSLUCENT
RenderLevelStageEvent.Stage.AFTER_WEATHER -> RenderEvent.Stage.LAST
RenderLevelStageEvent.Stage.AFTER_LEVEL -> RenderEvent.Stage.POST_LEVEL
else -> return
}
@@ -89,4 +91,4 @@ object NeoForgeClientPlatformEventHandler {
)
)
}
}
}