Compare commits

...
Author SHA1 Message Date
Plastered_Crab b0f0c20404 Initial testing for Shader support for our particles 2026-06-15 20:49:08 -07:00
4 changed files with 229 additions and 8 deletions
@@ -8,6 +8,7 @@
package com.cobblemon.mod.common.api.snowstorm
import com.cobblemon.mod.common.client.render.ShaderCompat
import com.mojang.blaze3d.platform.GlStateManager
import com.mojang.blaze3d.systems.RenderSystem
import com.mojang.blaze3d.vertex.BufferBuilder
@@ -27,7 +28,26 @@ object ParticleMaterials {
RenderSystem.depthMask(true)
RenderSystem.setShader { GameRenderer.getParticleShader() }
RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES)
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
// Premultiplied-alpha blending under an active shaderpack; straight alpha otherwise.
//
// WHY: Iris shaderpacks (Complementary Unbound/Reimagined, Bliss, Photon, ...) draw these
// custom particles through their *translucent* particle program, which composites with a
// premultiplied-alpha convention. Feeding it our straight-alpha colors makes faded
// (low-alpha) particles darken toward black instead of fading toward the background — the
// "gets darker as it fades" symptom. Switching to premultiplied blend (ONE, 1-SRC_ALPHA)
// AND premultiplying the vertex color by its alpha (see SnowstormParticle.render) matches
// that convention, so the fade looks ~like vanilla. This is one pack-agnostic code path:
// it targets the shared premultiplied convention, not any single pack.
//
// SAFE FOR THE MAJORITY: with premultiplied vertex color, `ONE * (rgb*a) + (1-a)*dst`
// equals straight alpha's `a*rgb + (1-a)*dst`, so the no-shader look is byte-identical;
// the branch only diverges while a shaderpack is active.
//
// RESIDUAL LIMITATION: a pack that explicitly declares `blend.gbuffers_particles_translucent`
// re-imposes its own blend and we cannot override that; and only the vertex alpha is
// premultiplied (not the texture's per-texel alpha), so very soft sprite edges may read
// slightly brighter than vanilla. The dominant over-lifetime fade is what this corrects.
applyTranslucentBlend()
return tessellator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE)
}
@@ -38,7 +58,20 @@ object ParticleMaterials {
override fun begin(tessellator: Tesselator, textureManager: TextureManager): BufferBuilder {
RenderSystem.enableBlend()
RenderSystem.disableCull()
RenderSystem.depthMask(true)
// Translucent (additive) particles should not write depth. With a shaderpack active
// (Iris/OptiFine) these custom render types are drawn through the pack's *translucent*
// particle program; if they also write depth they get treated as solid geometry by the
// pack's depth-dependent passes (fog, deferred lighting, translucent compositing), which
// visibly dims the additive glow — the worst-affected symptom. Dropping depth writes only
// when shaders are active keeps the vanilla (no-shader) look — used by the vast majority
// of players — byte-for-byte identical.
//
// RESIDUAL LIMITATION: the additive blendFunc below (SRC_ALPHA, ONE) is honored by Iris
// (it swaps the shader program but does not override blend for particles), but a
// shaderpack still has final authority over how its translucent particle program lights
// and composites the fragment. We route depth correctly and preserve additive blending,
// but we cannot force true additive parity against a pack that composites it otherwise.
RenderSystem.depthMask(!ShaderCompat.shadersActive())
RenderSystem.setShader { GameRenderer.getParticleShader() }
RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES)
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE)
@@ -52,10 +85,15 @@ object ParticleMaterials {
override fun begin(tessellator: Tesselator, textureManager: TextureManager): BufferBuilder {
RenderSystem.enableBlend()
RenderSystem.disableCull()
RenderSystem.depthMask(true)
// See ADD above: translucent particles drop depth writes only under an active shaderpack
// so the pack's translucent pass doesn't treat them as solid. No change to the vanilla look.
RenderSystem.depthMask(!ShaderCompat.shadersActive())
RenderSystem.setShader { GameRenderer.getParticleShader() }
RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES)
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
// Premultiplied-alpha under shaders, straight alpha otherwise — see ALPHA for the full
// rationale. Paired with the vertex-color premultiply in SnowstormParticle.render so the
// no-shader look is unchanged.
applyTranslucentBlend()
return tessellator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE)
}
@@ -75,4 +113,26 @@ object ParticleMaterials {
override fun toString() = "OPAQUE"
}
/**
* True when [material]'s vertex color must be premultiplied by its alpha before being submitted
* (see [ALPHA]). Only the alpha-composited materials need it, and only while a shaderpack is
* active; ADD is additive and OPAQUE is not blended, so neither is premultiplied. Kept here so
* the vertex path ([com.cobblemon.mod.common.client.render.SnowstormParticle.render]) and the
* blend func set in [applyTranslucentBlend] always agree on which particles are premultiplied.
*/
fun premultipliesUnderShaders(material: ParticleMaterial): Boolean =
(material == ParticleMaterial.ALPHA || material == ParticleMaterial.BLEND) && ShaderCompat.shadersActive()
/**
* Premultiplied-alpha blend (source already carries rgb*a, so source factor is ONE) while a
* shaderpack is active; vanilla straight-alpha blend otherwise. See [ALPHA] for why.
*/
private fun applyTranslucentBlend() {
if (ShaderCompat.shadersActive()) {
RenderSystem.blendFunc(GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
} else {
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
}
}
}
@@ -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
}
}
@@ -194,6 +194,16 @@ class SnowstormParticle(
val uvs = storm.effect.particle.uvMode.get(runtime, age / 20.0, lifetime / 20.0, uvDetails)
val colour = storm.getParticleColor() ?: storm.effect.particle.tinting.getTint(runtime)
// Under an active shaderpack the alpha-composited materials use premultiplied-alpha blending
// (see ParticleMaterials.ALPHA), so the color we submit must be premultiplied by its alpha to
// match. In vanilla this branch is skipped and the straight-alpha color is sent unchanged, so
// the no-shader look is identical. ADD/OPAQUE are never premultiplied.
val alpha = colour.w
val premultiply = ParticleMaterials.premultipliesUnderShaders(storm.effect.particle.material)
val red = if (premultiply) colour.x * alpha else colour.x
val green = if (premultiply) colour.y * alpha else colour.y
val blue = if (premultiply) colour.z * alpha else colour.z
val spriteURange = sprite.u1 - sprite.u0
val spriteVRange = sprite.v1 - sprite.v0
@@ -206,22 +216,22 @@ class SnowstormParticle(
vertexConsumer
.addVertex(particleVertices[0].x, particleVertices[0].y, particleVertices[0].z)
.setUv(maxU, maxV)
.setColor(colour.x, colour.y, colour.z, colour.w)
.setColor(red, green, blue, alpha)
.setLight(p)
vertexConsumer
.addVertex(particleVertices[1].x, particleVertices[1].y, particleVertices[1].z)
.setUv(maxU, minV)
.setColor(colour.x, colour.y, colour.z, colour.w)
.setColor(red, green, blue, alpha)
.setLight(p)
vertexConsumer
.addVertex(particleVertices[2].x, particleVertices[2].y, particleVertices[2].z)
.setUv(minU, minV)
.setColor(colour.x, colour.y, colour.z, colour.w)
.setColor(red, green, blue, alpha)
.setLight(p)
vertexConsumer
.addVertex(particleVertices[3].x, particleVertices[3].y, particleVertices[3].z)
.setUv(minU, maxV)
.setColor(colour.x, colour.y, colour.z, colour.w)
.setColor(red, green, blue, alpha)
.setLight(p)
}
+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)