mirror of
https://gitlab.com/cable-mc/cobblemon.git
synced 2026-10-29 21:38:53 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538b562b64 | ||
|
|
001450357a | ||
|
|
b0f0c20404 |
No files matched your search
+7
@@ -9,8 +9,10 @@
|
||||
package com.cobblemon.mod.common.compat.lambdynamiclights;
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon;
|
||||
import com.cobblemon.mod.common.client.particle.MoveDynamicLights;
|
||||
import dev.lambdaurora.lambdynlights.api.DynamicLightsContext;
|
||||
import dev.lambdaurora.lambdynlights.api.DynamicLightsInitializer;
|
||||
import dev.lambdaurora.lambdynlights.api.behavior.DynamicLightBehaviorManager;
|
||||
import dev.lambdaurora.lambdynlights.api.entity.luminance.EntityLuminance;
|
||||
import dev.lambdaurora.lambdynlights.api.item.ItemLightSourceManager;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
@@ -33,6 +35,11 @@ public class LambDynamicLightsInitializer implements DynamicLightsInitializer {
|
||||
context.entityLightSourceManager().onRegisterEvent().register(ctx -> {
|
||||
ctx.register(EntityType.PLAYER, PlayerLuminance.INSTANCE);
|
||||
});
|
||||
|
||||
// Attach a monochrome dynamic light to glowing particle effects (moves). The behavior reports
|
||||
// isRemoved() when its effect despawns, so the manager cleans it up automatically.
|
||||
final DynamicLightBehaviorManager behaviorManager = context.dynamicLightBehaviorManager();
|
||||
MoveDynamicLights.setSink(storm -> behaviorManager.add(new SnowstormDynamicLight(storm)));
|
||||
}
|
||||
|
||||
@SuppressWarnings({"removal", "UnstableApiUsage"})
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.compat.lambdynamiclights;
|
||||
|
||||
import com.cobblemon.mod.common.client.particle.ParticleStorm;
|
||||
import dev.lambdaurora.lambdynlights.api.behavior.DynamicLightBehavior;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* A (monochrome) dynamic light attached to a glowing Cobblemon particle effect, so the area around a
|
||||
* move lights up like any other dynamic light source. Brightness scales with how many particles the
|
||||
* effect currently has alive ({@link ParticleStorm#glowIntensity()}), and the light follows the live
|
||||
* particles' center (so a beam/AoE lights where the particles actually are, not the emitter origin on
|
||||
* the Pokémon). It removes itself when the effect despawns.
|
||||
*
|
||||
* <p>Vanilla block light is monochrome, so this changes the light <em>level</em> only — the move's
|
||||
* color is added separately by the screen-space colored pass ({@code MoveGlowRenderer}) when Iris is
|
||||
* also present.</p>
|
||||
*
|
||||
* <p>Only instantiated by {@link LambDynamicLightsInitializer} (the LambDynamicLights entrypoint), so
|
||||
* it is never loaded unless LambDynamicLights is installed.</p>
|
||||
*/
|
||||
public final class SnowstormDynamicLight implements DynamicLightBehavior {
|
||||
/** Peak block-light level (0..15) at full intensity. Keep modest — this is real light. */
|
||||
private static final int MAX_LUMINANCE = 8;
|
||||
|
||||
private final ParticleStorm storm;
|
||||
// Cached each hasChanged() so lightAtPos()/getBoundingBox() don't recompute the centroid per block.
|
||||
private double centerX;
|
||||
private double centerY;
|
||||
private double centerZ;
|
||||
private double luminance;
|
||||
private int prevX = Integer.MIN_VALUE;
|
||||
private int prevY = Integer.MIN_VALUE;
|
||||
private int prevZ = Integer.MIN_VALUE;
|
||||
private double prevLuminance = -1.0;
|
||||
|
||||
public SnowstormDynamicLight(ParticleStorm storm) {
|
||||
this.storm = storm;
|
||||
this.centerX = storm.getX();
|
||||
this.centerY = storm.getY();
|
||||
this.centerZ = storm.getZ();
|
||||
this.luminance = storm.glowIntensity() * MAX_LUMINANCE;
|
||||
}
|
||||
|
||||
/** Refreshes the cached center (live-particle centroid, fallback to emitter origin) and luminance. */
|
||||
private void updateState() {
|
||||
Vec3 centroid = storm.liveParticleCentroid();
|
||||
if (centroid != null) {
|
||||
centerX = centroid.x;
|
||||
centerY = centroid.y;
|
||||
centerZ = centroid.z;
|
||||
} else {
|
||||
centerX = storm.getX();
|
||||
centerY = storm.getY();
|
||||
centerZ = storm.getZ();
|
||||
}
|
||||
luminance = storm.glowIntensity() * MAX_LUMINANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double lightAtPos(BlockPos pos, double falloffRatio) {
|
||||
if (luminance <= 0.0) return 0.0;
|
||||
double dx = pos.getX() + 0.5 - centerX;
|
||||
double dy = pos.getY() + 0.5 - centerY;
|
||||
double dz = pos.getZ() + 0.5 - centerZ;
|
||||
double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
// Standard block-light falloff: drops ~1 level per block from the center.
|
||||
return Math.max(0.0, luminance - dist);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DynamicLightBehavior.BoundingBox getBoundingBox() {
|
||||
int r = Math.max(1, Math.min(MAX_LUMINANCE, (int) Math.ceil(luminance)));
|
||||
int cx = (int) Math.floor(centerX);
|
||||
int cy = (int) Math.floor(centerY);
|
||||
int cz = (int) Math.floor(centerZ);
|
||||
return new DynamicLightBehavior.BoundingBox(cx - r, cy - r, cz - r, cx + r, cy + r, cz + r);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasChanged() {
|
||||
updateState();
|
||||
int cx = (int) Math.floor(centerX);
|
||||
int cy = (int) Math.floor(centerY);
|
||||
int cz = (int) Math.floor(centerZ);
|
||||
boolean changed = cx != prevX || cy != prevY || cz != prevZ || Math.abs(luminance - prevLuminance) >= 1.0;
|
||||
if (changed) {
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
prevZ = cz;
|
||||
prevLuminance = luminance;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRemoved() {
|
||||
return storm.getDespawned();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
package com.cobblemon.mod.common.api.snowstorm
|
||||
|
||||
import com.cobblemon.mod.common.client.render.ShaderCompat
|
||||
import com.cobblemon.mod.common.client.render.ShaderCompat.ShaderParticleProfile
|
||||
import com.mojang.blaze3d.platform.GlStateManager
|
||||
import com.mojang.blaze3d.systems.RenderSystem
|
||||
import com.mojang.blaze3d.vertex.BufferBuilder
|
||||
@@ -19,15 +21,28 @@ import net.minecraft.client.renderer.GameRenderer
|
||||
import net.minecraft.client.renderer.texture.TextureAtlas
|
||||
import net.minecraft.client.renderer.texture.TextureManager
|
||||
|
||||
/**
|
||||
* The four particle render types for snowstorm particles.
|
||||
*
|
||||
* Under an active shaderpack the blend/depth state of the translucent materials is driven by a
|
||||
* per-pack [ShaderParticleProfile] (see [ShaderCompat]) so each pack can be tuned independently —
|
||||
* Iris routes our particles through each pack's translucent particle program, and no single blend
|
||||
* works everywhere. With no shaderpack the profile is [ShaderCompat.VANILLA_PROFILE], reproducing
|
||||
* stock blending exactly, so the no-shader majority path is unchanged.
|
||||
*
|
||||
* [begin] refreshes the cached profile once per particle batch; the per-particle vertex path reads it
|
||||
* via [premultipliesUnderShaders] (see [com.cobblemon.mod.common.client.render.SnowstormParticle.render]).
|
||||
*/
|
||||
object ParticleMaterials {
|
||||
val ALPHA = object : ParticleRenderType {
|
||||
override fun begin(tessellator: Tesselator, textureManager: TextureManager): BufferBuilder {
|
||||
val profile = ShaderCompat.refreshParticleProfile()
|
||||
RenderSystem.enableBlend()
|
||||
RenderSystem.enableCull()
|
||||
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)
|
||||
applyTranslucentBlend(profile)
|
||||
return tessellator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE)
|
||||
}
|
||||
|
||||
@@ -36,9 +51,13 @@ object ParticleMaterials {
|
||||
|
||||
val ADD = object : ParticleRenderType {
|
||||
override fun begin(tessellator: Tesselator, textureManager: TextureManager): BufferBuilder {
|
||||
val profile = ShaderCompat.refreshParticleProfile()
|
||||
RenderSystem.enableBlend()
|
||||
RenderSystem.disableCull()
|
||||
RenderSystem.depthMask(true)
|
||||
// Additive glow generally shouldn't write depth under shaders or the pack treats it as
|
||||
// solid and dims it — but some packs need the depth write or the particles vanish, so the
|
||||
// choice is per-pack (profile.addWritesDepth). Vanilla profile = true (unchanged look).
|
||||
RenderSystem.depthMask(profile.addWritesDepth)
|
||||
RenderSystem.setShader { GameRenderer.getParticleShader() }
|
||||
RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES)
|
||||
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE)
|
||||
@@ -50,12 +69,13 @@ object ParticleMaterials {
|
||||
|
||||
val BLEND = object : ParticleRenderType {
|
||||
override fun begin(tessellator: Tesselator, textureManager: TextureManager): BufferBuilder {
|
||||
val profile = ShaderCompat.refreshParticleProfile()
|
||||
RenderSystem.enableBlend()
|
||||
RenderSystem.disableCull()
|
||||
RenderSystem.depthMask(true)
|
||||
RenderSystem.depthMask(profile.blendWritesDepth)
|
||||
RenderSystem.setShader { GameRenderer.getParticleShader() }
|
||||
RenderSystem.setShaderTexture(0, TextureAtlas.LOCATION_PARTICLES)
|
||||
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
|
||||
applyTranslucentBlend(profile)
|
||||
return tessellator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.PARTICLE)
|
||||
}
|
||||
|
||||
@@ -64,6 +84,7 @@ object ParticleMaterials {
|
||||
|
||||
val OPAQUE = object : ParticleRenderType {
|
||||
override fun begin(tessellator: Tesselator, textureManager: TextureManager): BufferBuilder {
|
||||
ShaderCompat.refreshParticleProfile()
|
||||
RenderSystem.enableBlend()
|
||||
RenderSystem.enableCull()
|
||||
RenderSystem.depthMask(true)
|
||||
@@ -75,4 +96,29 @@ object ParticleMaterials {
|
||||
|
||||
override fun toString() = "OPAQUE"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [material]'s vertex color must be premultiplied by its alpha before being submitted.
|
||||
* Driven by the cached per-pack profile, so the vertex path
|
||||
* ([com.cobblemon.mod.common.client.render.SnowstormParticle.render]) and the blend func set in
|
||||
* [applyTranslucentBlend] always agree. Only the alpha-composited materials (ALPHA/BLEND) are
|
||||
* premultiplied; ADD is additive and OPAQUE is not blended. With no shaderpack the profile is
|
||||
* VANILLA (premultiplyAlpha = false), so this returns false and the look is stock.
|
||||
*/
|
||||
fun premultipliesUnderShaders(material: ParticleMaterial): Boolean =
|
||||
ShaderCompat.particleProfile().premultiplyAlpha &&
|
||||
(material == ParticleMaterial.ALPHA || material == ParticleMaterial.BLEND)
|
||||
|
||||
/**
|
||||
* Premultiplied-alpha blend (source already carries rgb*a, so source factor is ONE) when the
|
||||
* active pack's profile asks for it; vanilla straight-alpha blend otherwise. Because
|
||||
* `ONE*(rgb*a) + (1-a)*dst == a*rgb + (1-a)*dst`, the vanilla profile is byte-identical to stock.
|
||||
*/
|
||||
private fun applyTranslucentBlend(profile: ShaderParticleProfile) {
|
||||
if (profile.premultiplyAlpha) {
|
||||
RenderSystem.blendFunc(GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
|
||||
} else {
|
||||
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.MoveGlowRenderer
|
||||
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() }
|
||||
|
||||
// Colored screen-space "dynamic light" for glowing moves (only acts when shaders + LambDynamicLights present).
|
||||
MoveGlowRenderer.register()
|
||||
|
||||
this.registerBlockEntityRenderers()
|
||||
registerBlockRenderTypes()
|
||||
registerColors()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.particle
|
||||
|
||||
/**
|
||||
* API-free bridge that lets the optional LambDynamicLights integration attach a dynamic light to
|
||||
* glowing particle effects without the common particle code referencing the LambDynamicLights API.
|
||||
*
|
||||
* When LambDynamicLights is installed, its entrypoint
|
||||
* ([com.cobblemon.mod.common.compat.lambdynamiclights.LambDynamicLightsInitializer]) sets [sink] to a
|
||||
* handler that registers a `DynamicLightBehavior` for the storm. When it isn't installed, [sink] stays
|
||||
* null and [ParticleStorm.spawn]'s call is a no-op — so there is no hard dependency.
|
||||
*/
|
||||
object MoveDynamicLights {
|
||||
@JvmStatic
|
||||
var sink: GlowingStormLightSink? = null
|
||||
}
|
||||
|
||||
/** Receives glow-capable [ParticleStorm]s so a (monochrome) dynamic light can be attached to them. */
|
||||
fun interface GlowingStormLightSink {
|
||||
fun register(storm: ParticleStorm)
|
||||
}
|
||||
@@ -91,6 +91,9 @@ class ParticleStorm(
|
||||
runtime.environment.setSimpleVariable("entity_scale", DoubleValue((entity as? PokemonEntity)?.scale ?: 1.0))
|
||||
}
|
||||
Minecraft.getInstance().particleEngine.add(this)
|
||||
activeStorms.add(this)
|
||||
// Glow-capable effects get a (monochrome) dynamic light when LambDynamicLights is installed.
|
||||
if (emitsLight) MoveDynamicLights.sink?.register(this)
|
||||
}
|
||||
|
||||
fun addAttackingFunctions(destinationPosSupplier: (() -> Vec3)) {
|
||||
@@ -150,6 +153,15 @@ class ParticleStorm(
|
||||
companion object {
|
||||
var contextStorm: ParticleStorm? = null
|
||||
|
||||
/**
|
||||
* Every live particle effect, so [com.cobblemon.mod.common.client.render.MoveGlowRenderer] can
|
||||
* cast a colored glow around the glowing ones. Insertion-ordered; storms use identity equality.
|
||||
*/
|
||||
val activeStorms: MutableSet<ParticleStorm> = LinkedHashSet()
|
||||
|
||||
/** Live particle count at which a glowing effect reaches full glow intensity. */
|
||||
const val GLOW_FULL_PARTICLE_COUNT = 20f
|
||||
|
||||
fun createAtPosition(world: ClientLevel, effect: BedrockParticleOptions, position: Vec3): ParticleStorm {
|
||||
val wrapper = MatrixWrapper()
|
||||
val matrix = PoseStack()
|
||||
@@ -218,6 +230,7 @@ class ParticleStorm(
|
||||
|
||||
override fun remove() {
|
||||
super.remove()
|
||||
activeStorms.remove(this)
|
||||
if (!despawned) {
|
||||
effect.emitter.expirationEvents.forEach { it.trigger(this, null) }
|
||||
despawned = true
|
||||
@@ -374,4 +387,50 @@ class ParticleStorm(
|
||||
fun setInvisible() {
|
||||
particles.forEach { particle -> particle.invisible = true }
|
||||
}
|
||||
|
||||
/** True when this effect is glow-capable (self-lit rather than environment-lit), regardless of
|
||||
* current particle count — used to decide whether to attach a dynamic light at spawn. */
|
||||
val emitsLight: Boolean
|
||||
get() = !effect.particle.environmentLighting
|
||||
|
||||
/** True when this effect is currently glowing (glow-capable AND actively emitting particles). */
|
||||
val isGlowing: Boolean
|
||||
get() = emitsLight && !stopped && particles.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Representative color for the move glow. Uses the color a live particle actually rendered with
|
||||
* (the tint is evaluated safely there, with particle-age context); the explicit particle color is
|
||||
* the fallback, then white. We must NOT call [effect].particle.tinting.getTint here — outside a
|
||||
* particle render the runtime has no age context and a gradient tint throws.
|
||||
*/
|
||||
fun glowColor(): Vector4f =
|
||||
particles.firstOrNull()?.lastColor ?: getParticleColor() ?: Vector4f(1f, 1f, 1f, 1f)
|
||||
|
||||
/** 0..1 glow strength from how many particles are currently alive (more particles -> brighter). */
|
||||
fun glowIntensity(): Float = (particles.size.toFloat() / GLOW_FULL_PARTICLE_COUNT).coerceIn(0f, 1f)
|
||||
|
||||
/**
|
||||
* Average position of the live (visible) particles, or null if there are none. Used to position
|
||||
* the monochrome dynamic light where the particles actually are (following a beam/AoE) instead of
|
||||
* pinning it to the emitter origin on the Pokémon.
|
||||
*/
|
||||
/**
|
||||
* For targeted (beam/projectile) effects, the light segment from the emitter to the target, so the
|
||||
* colored light can illuminate the whole beam path rather than a single point. Null for effects
|
||||
* with no target (bursts/AoE), which fall back to per-particle sampling.
|
||||
*/
|
||||
fun beamEndpoints(): Pair<Vec3, Vec3>? {
|
||||
val end = targetPos?.invoke() ?: return null
|
||||
return Vec3(getX(), getY(), getZ()) to end
|
||||
}
|
||||
|
||||
fun liveParticleCentroid(): Vec3? {
|
||||
var n = 0
|
||||
var sx = 0.0; var sy = 0.0; var sz = 0.0
|
||||
for (p in particles) {
|
||||
if (p.invisible) continue
|
||||
sx += p.getX(); sy += p.getY(); sz += p.getZ(); n++
|
||||
}
|
||||
return if (n == 0) null else Vec3(sx / n, sy / n, sz / n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.client.particle.MoveDynamicLights
|
||||
import com.cobblemon.mod.common.client.particle.ParticleStorm
|
||||
import com.cobblemon.mod.common.client.render.shader.CobblemonShaders
|
||||
import com.cobblemon.mod.common.platform.events.PlatformEvents
|
||||
import com.cobblemon.mod.common.platform.events.RenderEvent
|
||||
import com.mojang.blaze3d.pipeline.TextureTarget
|
||||
import com.mojang.blaze3d.platform.GlStateManager
|
||||
import com.mojang.blaze3d.systems.RenderSystem
|
||||
import com.mojang.blaze3d.vertex.BufferUploader
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat
|
||||
import com.mojang.blaze3d.vertex.Tesselator
|
||||
import com.mojang.blaze3d.vertex.VertexFormat
|
||||
import net.minecraft.client.Minecraft
|
||||
import net.minecraft.world.phys.Vec3
|
||||
import org.joml.Matrix4f
|
||||
import org.joml.Vector3f
|
||||
import org.lwjgl.opengl.GL14
|
||||
|
||||
/**
|
||||
* Tier 2 of the move lighting: a **colored** screen-space "dynamic light" for glowing moves, active
|
||||
* only when **both LambDynamicLights and a shaderpack (Iris/OptiFine)** are present (Lamb alone gives
|
||||
* the monochrome light level via
|
||||
* [com.cobblemon.mod.common.compat.lambdynamiclights.SnowstormDynamicLight]; this adds the move color).
|
||||
*
|
||||
* Colored world illumination has no mod API on any pack, so this is a deferred screen-space effect:
|
||||
* after the world (and shaderpack) has composited, each glowing [ParticleStorm] contributes light(s)
|
||||
* that read the scene depth, reconstruct each surface's position, and tint it with the move's color
|
||||
* and distance falloff. Beams light along the whole emitter->target segment; bursts sample several
|
||||
* real particles. The *surfaces* near the move are tinted (no visible shape), identically on every
|
||||
* pack because it runs post-composite on vanilla GL.
|
||||
*
|
||||
* ## Non-additive accumulation
|
||||
* All the lights are first drawn into an off-screen [glowTarget] with a **MAX** blend, so overlapping
|
||||
* lights/effects keep the **brightest** contribution rather than summing — no additive blow-out to
|
||||
* white when many effects (or a dense burst's samples) overlap. That buffer is then composited onto
|
||||
* the scene **once** (additive), bounded by a single light's [PER_LIGHT_INTENSITY].
|
||||
*/
|
||||
object MoveGlowRenderer {
|
||||
|
||||
/** How many particles to sample per burst emitter as light points (spread evenly). */
|
||||
private const val SAMPLES_PER_EMITTER = 10
|
||||
/** Falloff radius of each colored light, in blocks. */
|
||||
private const val LIGHT_RADIUS = 10.0f
|
||||
/** Strength of a single light. Because accumulation is MAX (not additive), this is the actual peak
|
||||
* glow a pixel can receive no matter how many lights overlap. */
|
||||
private const val PER_LIGHT_INTENSITY = 0.2f
|
||||
/** Safety cap on total light passes per frame (each is a fullscreen pass into the glow buffer). */
|
||||
private const val GLOBAL_MAX_LIGHTS = 60
|
||||
|
||||
// World matrices for the frame, captured at TRANSLUCENT (known-good) for the post-composite pass.
|
||||
private val modelView = Matrix4f()
|
||||
private val inverseProjection = Matrix4f()
|
||||
private var captured = false
|
||||
|
||||
// Off-screen buffer the lights MAX-accumulate into before being composited onto the scene once.
|
||||
private var glowTarget: TextureTarget? = null
|
||||
|
||||
fun register() {
|
||||
PlatformEvents.RENDER.subscribe { event ->
|
||||
when (event.stage) {
|
||||
RenderEvent.Stage.TRANSLUCENT -> {
|
||||
modelView.set(event.modelViewMatrix)
|
||||
inverseProjection.set(event.projectionMatrix).invert()
|
||||
captured = true
|
||||
}
|
||||
RenderEvent.Stage.POST_LEVEL -> render(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(event: RenderEvent) {
|
||||
if (!captured) return
|
||||
// Colored layer is gated on BOTH a shaderpack AND LambDynamicLights being present.
|
||||
if (!ShaderCompat.shadersActive()) return
|
||||
if (MoveDynamicLights.sink == null) return
|
||||
if (!CobblemonShaders.isMoveGlowLightReady()) return
|
||||
|
||||
val storms = ParticleStorm.activeStorms.filter { it.isGlowing }
|
||||
if (storms.isEmpty()) return
|
||||
|
||||
val minecraft = Minecraft.getInstance()
|
||||
val main = minecraft.mainRenderTarget
|
||||
val target = ensureGlowTarget(main.width, main.height)
|
||||
val cam = event.camera.position
|
||||
val lightShader = CobblemonShaders.MOVE_GLOW_LIGHT
|
||||
|
||||
// --- Pass 1: accumulate all move lights into the off-screen buffer with MAX (no stacking). ---
|
||||
target.setClearColor(0f, 0f, 0f, 0f)
|
||||
target.clear(Minecraft.ON_OSX)
|
||||
target.bindWrite(true)
|
||||
|
||||
RenderSystem.disableDepthTest()
|
||||
RenderSystem.depthMask(false)
|
||||
RenderSystem.disableCull()
|
||||
RenderSystem.enableBlend()
|
||||
GlStateManager._blendEquation(GL14.GL_MAX) // result = max(src, dst): brightest light wins, no sum
|
||||
RenderSystem.setShader { lightShader }
|
||||
lightShader.safeGetUniform("InverseProjMat").set(inverseProjection)
|
||||
lightShader.setSampler("DepthSampler", main.depthTextureId) // read the real scene depth
|
||||
lightShader.safeGetUniform("LightRadius")?.set(LIGHT_RADIUS)
|
||||
|
||||
var lightsDrawn = 0
|
||||
emitters@ for (storm in storms) {
|
||||
try {
|
||||
val beam = storm.beamEndpoints()
|
||||
if (beam != null) {
|
||||
val intensity = PER_LIGHT_INTENSITY * storm.glowIntensity()
|
||||
if (intensity > 0f) {
|
||||
val a = viewSpace(beam.first.x, beam.first.y, beam.first.z, cam)
|
||||
val b = viewSpace(beam.second.x, beam.second.y, beam.second.z, cam)
|
||||
val colour = storm.glowColor()
|
||||
lightShader.safeGetUniform("LightViewPosA")?.set(a.x, a.y, a.z)
|
||||
lightShader.safeGetUniform("LightViewPosB")?.set(b.x, b.y, b.z)
|
||||
lightShader.safeGetUniform("LightColor")?.set(colour.x, colour.y, colour.z)
|
||||
lightShader.safeGetUniform("Intensity")?.set(intensity)
|
||||
drawFullscreenQuad()
|
||||
if (++lightsDrawn >= GLOBAL_MAX_LIGHTS) break@emitters
|
||||
}
|
||||
} else {
|
||||
val particles = storm.particles
|
||||
if (particles.isEmpty()) continue
|
||||
val samples = minOf(SAMPLES_PER_EMITTER, particles.size)
|
||||
val step = (particles.size / samples).coerceAtLeast(1)
|
||||
for (s in 0 until samples) {
|
||||
val particle = particles.getOrNull(s * step) ?: continue
|
||||
if (particle.invisible) continue
|
||||
val colour = particle.lastColor
|
||||
val alpha = colour.w
|
||||
if (alpha <= 0.01f) continue
|
||||
val p = viewSpace(particle.getX(), particle.getY(), particle.getZ(), cam)
|
||||
lightShader.safeGetUniform("LightViewPosA")?.set(p.x, p.y, p.z)
|
||||
lightShader.safeGetUniform("LightViewPosB")?.set(p.x, p.y, p.z)
|
||||
lightShader.safeGetUniform("LightColor")?.set(colour.x, colour.y, colour.z)
|
||||
lightShader.safeGetUniform("Intensity")?.set(PER_LIGHT_INTENSITY * alpha)
|
||||
drawFullscreenQuad()
|
||||
if (++lightsDrawn >= GLOBAL_MAX_LIGHTS) break@emitters
|
||||
}
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
// A single misbehaving effect must never break the world-render callback.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pass 2: composite the accumulated glow onto the scene once (additive). ---
|
||||
GlStateManager._blendEquation(GL14.GL_FUNC_ADD)
|
||||
main.bindWrite(true)
|
||||
RenderSystem.blendFunc(GlStateManager.SourceFactor.ONE, GlStateManager.DestFactor.ONE)
|
||||
val compositeShader = CobblemonShaders.MOVE_GLOW_COMPOSITE
|
||||
RenderSystem.setShader { compositeShader }
|
||||
compositeShader.setSampler("GlowSampler", target.colorTextureId)
|
||||
drawFullscreenQuad()
|
||||
|
||||
// Restore neutral GL state.
|
||||
RenderSystem.depthMask(true)
|
||||
RenderSystem.enableDepthTest()
|
||||
RenderSystem.enableCull()
|
||||
RenderSystem.disableBlend()
|
||||
RenderSystem.defaultBlendFunc()
|
||||
}
|
||||
|
||||
private fun ensureGlowTarget(width: Int, height: Int): TextureTarget {
|
||||
var target = glowTarget
|
||||
if (target == null) {
|
||||
target = TextureTarget(width, height, false, Minecraft.ON_OSX)
|
||||
glowTarget = target
|
||||
} else if (target.width != width || target.height != height) {
|
||||
target.resize(width, height, Minecraft.ON_OSX)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/** World position -> view space (camera-relative, then the world model-view), for the shader. */
|
||||
private fun viewSpace(wx: Double, wy: Double, wz: Double, cam: Vec3): Vector3f {
|
||||
val v = Vector3f((wx - cam.x).toFloat(), (wy - cam.y).toFloat(), (wz - cam.z).toFloat())
|
||||
modelView.transformPosition(v)
|
||||
return v
|
||||
}
|
||||
|
||||
/** A quad covering clip space (-1..1); the vertex shader passes it straight through. */
|
||||
private fun drawFullscreenQuad() {
|
||||
val buffer = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION)
|
||||
buffer.addVertex(-1f, -1f, 0f)
|
||||
buffer.addVertex(1f, -1f, 0f)
|
||||
buffer.addVertex(1f, 1f, 0f)
|
||||
buffer.addVertex(-1f, 1f, 0f)
|
||||
val mesh = buffer.build() ?: return
|
||||
BufferUploader.drawWithShader(mesh)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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.LOGGER
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
* Detects whether a shaderpack is active and, if so, *which* one, then exposes a per-pack
|
||||
* [ShaderParticleProfile] describing how Cobblemon's snowstorm particles should be blended so they
|
||||
* look right under that specific pack.
|
||||
*
|
||||
* ## Why per-pack?
|
||||
* Iris/OptiFine route our custom translucent particles through the shaderpack's own
|
||||
* `gbuffers_particles_translucent` program, and every pack composites that program differently.
|
||||
* A single pack-agnostic treatment is impossible: premultiplied-alpha fixes Complementary's
|
||||
* fade-to-black but makes Photon's particles vanish and leaves Bliss darkening. So instead we
|
||||
* identify the pack and apply a tailored blend/depth profile. **Tune the table in [profileFor].**
|
||||
*
|
||||
* Everything here is reflective and fully guarded: Iris/OptiFine are optional and may be absent at
|
||||
* runtime, so we never hard-reference their classes. Any reflective failure degrades to "no shaders"
|
||||
* / the vanilla profile and never throws. Works identically on Fabric and NeoForge.
|
||||
*/
|
||||
object ShaderCompat {
|
||||
|
||||
/** Identifies the active shaderpack family (by name substring). [OTHER] = active but unrecognised. */
|
||||
enum class ShaderPack { NONE, COMPLEMENTARY, BLISS, PHOTON, BSL, OTHER }
|
||||
|
||||
/**
|
||||
* How to blend the snowstorm particle materials under a given pack. These are the only knobs we
|
||||
* actually control in-pipeline (vertex color + GL blend/depth state); the pack's fragment program
|
||||
* is out of reach.
|
||||
*
|
||||
* @param premultiplyAlpha ALPHA & BLEND: premultiply the vertex color by its alpha and blend with
|
||||
* `(ONE, ONE_MINUS_SRC_ALPHA)`. Cures "fades to black" on packs that composite premultiplied,
|
||||
* but can read too opaque on packs that already premultiply internally. (No-shader path: false.)
|
||||
* @param addWritesDepth ADD material `depthMask`. `false` stops the pack treating additive glow
|
||||
* as solid geometry (dimming via fog/deferred lighting); some packs need `true` or particles
|
||||
* disappear. (No-shader path: true.)
|
||||
* @param blendWritesDepth BLEND material `depthMask`, same trade-off as [addWritesDepth].
|
||||
*/
|
||||
data class ShaderParticleProfile(
|
||||
val premultiplyAlpha: Boolean,
|
||||
val addWritesDepth: Boolean,
|
||||
val blendWritesDepth: Boolean,
|
||||
/**
|
||||
* Clamp emitted vertex alpha to at least this (0 = no clamp). Defeats packs that `discard`
|
||||
* low-alpha particle fragments — Photon's shared translucent program does
|
||||
* `if (fragment_color.a < 0.1) discard;`, so faded/soft particles vanish. ~0.12–0.2 keeps the
|
||||
* particle core above the floor. (Vanilla/most packs: 0.) Trade-off: particles can't fade
|
||||
* fully to transparent under that pack.
|
||||
*/
|
||||
val minAlpha: Float = 0f
|
||||
)
|
||||
|
||||
/** The vanilla, no-shaderpack profile — must reproduce stock Minecraft particle blending exactly. */
|
||||
val VANILLA_PROFILE = ShaderParticleProfile(premultiplyAlpha = false, addWritesDepth = true, blendWritesDepth = true)
|
||||
|
||||
// --- reflective handles, resolved once ------------------------------------------------------
|
||||
|
||||
// Iris: net.irisshaders.iris.api.v0.IrisApi.getInstance().isShaderPackInUse()
|
||||
private val irisInstance: Any?
|
||||
private val irisIsShaderPackInUse: Method?
|
||||
// Iris: net.irisshaders.iris.Iris.getCurrentPackName() (static) -> String, "(off)" when none.
|
||||
private val irisGetCurrentPackName: Method?
|
||||
|
||||
// OptiFine: net.optifine.Config.isShaders() (static) + net.optifine.shaders.Shaders.getShaderPackName()
|
||||
private val optifineIsShaders: Method?
|
||||
private val optifineGetShaderPackName: Method?
|
||||
|
||||
init {
|
||||
var irisInst: Any? = null
|
||||
var irisInUse: Method? = null
|
||||
var irisPackName: Method? = null
|
||||
try {
|
||||
val irisApi = Class.forName("net.irisshaders.iris.api.v0.IrisApi")
|
||||
irisInst = irisApi.getMethod("getInstance").invoke(null)
|
||||
irisInUse = irisApi.getMethod("isShaderPackInUse")
|
||||
} catch (ignored: Throwable) { irisInst = null; irisInUse = null }
|
||||
try {
|
||||
irisPackName = Class.forName("net.irisshaders.iris.Iris").getMethod("getCurrentPackName")
|
||||
} catch (ignored: Throwable) { irisPackName = null }
|
||||
irisInstance = irisInst
|
||||
irisIsShaderPackInUse = irisInUse
|
||||
irisGetCurrentPackName = irisPackName
|
||||
|
||||
var ofIsShaders: Method? = null
|
||||
var ofPackName: Method? = null
|
||||
try {
|
||||
ofIsShaders = Class.forName("net.optifine.Config").getMethod("isShaders")
|
||||
} catch (ignored: Throwable) { ofIsShaders = null }
|
||||
try {
|
||||
ofPackName = Class.forName("net.optifine.shaders.Shaders").getMethod("getShaderPackName")
|
||||
} catch (ignored: Throwable) { ofPackName = null }
|
||||
optifineIsShaders = ofIsShaders
|
||||
optifineGetShaderPackName = ofPackName
|
||||
}
|
||||
|
||||
// --- detection ------------------------------------------------------------------------------
|
||||
|
||||
/** `true` when Iris or OptiFine reports an active (compiled and enabled) shaderpack. Never throws. */
|
||||
fun shadersActive(): Boolean {
|
||||
val inst = irisInstance
|
||||
val inUse = irisIsShaderPackInUse
|
||||
if (inst != null && inUse != null) {
|
||||
try { if (inUse.invoke(inst) == true) return true } catch (ignored: Throwable) {}
|
||||
}
|
||||
val of = optifineIsShaders
|
||||
if (of != null) {
|
||||
try { if (of.invoke(null) == true) return true } catch (ignored: Throwable) {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Active shaderpack display name (e.g. "ComplementaryUnbound_r5.3.zip"), or null if none/unknown. */
|
||||
fun activePackName(): String? {
|
||||
if (!shadersActive()) return null
|
||||
irisGetCurrentPackName?.let { m ->
|
||||
try {
|
||||
val name = m.invoke(null) as? String
|
||||
if (!name.isNullOrBlank() && !name.equals("(off)", ignoreCase = true) && !name.equals("(internal)", ignoreCase = true)) {
|
||||
return name
|
||||
}
|
||||
} catch (ignored: Throwable) {}
|
||||
}
|
||||
optifineGetShaderPackName?.let { m ->
|
||||
try {
|
||||
val name = m.invoke(null) as? String
|
||||
if (!name.isNullOrBlank() && !name.equals("OFF", ignoreCase = true) && !name.equals("(internal)", ignoreCase = true)) {
|
||||
return name
|
||||
}
|
||||
} catch (ignored: Throwable) {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Classifies the active pack into a [ShaderPack] family by name substring. */
|
||||
fun activePack(): ShaderPack {
|
||||
val name = activePackName()?.lowercase() ?: return if (shadersActive()) ShaderPack.OTHER else ShaderPack.NONE
|
||||
return when {
|
||||
"complementary" in name -> ShaderPack.COMPLEMENTARY
|
||||
"bliss" in name -> ShaderPack.BLISS
|
||||
"photon" in name -> ShaderPack.PHOTON
|
||||
"bsl" in name -> ShaderPack.BSL
|
||||
else -> ShaderPack.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-pack profile -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* THE TUNING TABLE. Maps each recognised pack to its particle blend profile. These are starting
|
||||
* points based on observed symptoms — adjust freely while exploring; only the shaders-active path
|
||||
* is affected (NONE always returns [VANILLA_PROFILE], so non-shader users are never touched).
|
||||
*/
|
||||
private fun profileFor(pack: ShaderPack): ShaderParticleProfile = when (pack) {
|
||||
ShaderPack.NONE -> VANILLA_PROFILE
|
||||
// Premultiplied fixes the fade-to-black; trade-off is it can look a touch too opaque.
|
||||
ShaderPack.COMPLEMENTARY -> ShaderParticleProfile(premultiplyAlpha = true, addWritesDepth = false, blendWritesDepth = false)
|
||||
// Bliss force-overrides blend to SRC_ALPHA (blend.gbuffers_textured), so premultiplied color
|
||||
// gets multiplied by alpha twice -> the darkening. Straight alpha fixes it. Brightness/glow is
|
||||
// driven by the lightmap (Bliss relights particles as (Indirect+Direct)*Albedo): glow particles
|
||||
// are emitted full-bright via environmentLighting=false and stay bright; non-glow ones use world
|
||||
// light and read as environment-lit, exactly as intended.
|
||||
ShaderPack.BLISS -> ShaderParticleProfile(premultiplyAlpha = false, addWritesDepth = false, blendWritesDepth = false)
|
||||
// Photon (MC <=1.21.1) disables its dedicated particle programs and routes particles through a
|
||||
// shared translucent program that (a) discards fragments with alpha < 0.1, (b) force-overrides
|
||||
// blend to straight alpha-over, and (c) relights RGB with diffuse-lighting * alpha (NoL needs a
|
||||
// normal we can't supply). Straight alpha + lift alpha above the 0.1 discard floor; glow comes
|
||||
// from the full-bright lightmap of environmentLighting=false particles. NOTE: true additive
|
||||
// glow can't be reproduced here (Photon's relight*alpha + forced alpha-over) — bypass needed.
|
||||
ShaderPack.PHOTON -> ShaderParticleProfile(
|
||||
premultiplyAlpha = false,
|
||||
addWritesDepth = true,
|
||||
blendWritesDepth = true,
|
||||
minAlpha = 0.15f
|
||||
)
|
||||
ShaderPack.BSL -> ShaderParticleProfile(premultiplyAlpha = true, addWritesDepth = false, blendWritesDepth = false)
|
||||
ShaderPack.OTHER -> ShaderParticleProfile(premultiplyAlpha = true, addWritesDepth = false, blendWritesDepth = false)
|
||||
}
|
||||
|
||||
// Cached so per-particle reads (premultipliesUnderShaders) are free; refreshed once per particle
|
||||
// batch from ParticleRenderType.begin() (a few times per frame), where re-querying is negligible.
|
||||
@Volatile
|
||||
private var cachedProfile: ShaderParticleProfile = VANILLA_PROFILE
|
||||
private var lastLoggedPack: ShaderPack? = null
|
||||
|
||||
/** Recomputes and caches the active profile. Call once per particle batch (from begin()). */
|
||||
fun refreshParticleProfile(): ShaderParticleProfile {
|
||||
val pack = activePack()
|
||||
if (pack != lastLoggedPack) {
|
||||
lastLoggedPack = pack
|
||||
LOGGER.info("Cobblemon snowstorm particle shader profile -> $pack (${activePackName() ?: "no pack"})")
|
||||
}
|
||||
val profile = profileFor(pack)
|
||||
cachedProfile = profile
|
||||
return profile
|
||||
}
|
||||
|
||||
/** The profile from the most recent [refreshParticleProfile]; cheap, no reflection. */
|
||||
fun particleProfile(): ShaderParticleProfile = cachedProfile
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import org.joml.AxisAngle4d
|
||||
import org.joml.Quaterniond
|
||||
import org.joml.Vector3d
|
||||
import org.joml.Vector3f
|
||||
import org.joml.Vector4f
|
||||
import kotlin.random.Random
|
||||
|
||||
class SnowstormParticle(
|
||||
@@ -64,6 +65,10 @@ class SnowstormParticle(
|
||||
|
||||
var texture = storm.effect.particle.texture
|
||||
|
||||
/** The color this particle last rendered with (tint evaluated safely in [render] with the proper
|
||||
* runtime context). Sampled by [ParticleStorm.glowColor] for the move's dynamic-light color. */
|
||||
var lastColor: Vector4f = Vector4f(1f, 1f, 1f, 1f)
|
||||
|
||||
var localX = x - storm.getX()
|
||||
var localY = y - storm.getY()
|
||||
var localZ = z - storm.getZ()
|
||||
@@ -193,6 +198,19 @@ 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)
|
||||
lastColor = colour
|
||||
|
||||
// Per-pack shader treatment (see ShaderCompat.ShaderParticleProfile). With no shaderpack the
|
||||
// profile is VANILLA, so every branch below is a no-op and the look is stock.
|
||||
val profile = ShaderCompat.particleProfile()
|
||||
// minAlpha lifts alpha above a pack's discard floor (e.g. Photon discards alpha < 0.1) so
|
||||
// faded/soft particles aren't culled. 0 for vanilla/most packs.
|
||||
val alpha = maxOf(colour.w, profile.minAlpha)
|
||||
// Premultiplied-alpha materials need the color premultiplied by alpha to match the ONE blend.
|
||||
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
|
||||
@@ -202,26 +220,31 @@ class SnowstormParticle(
|
||||
val minV = uvs.startV * spriteVRange + sprite.v0
|
||||
val maxV = uvs.endV * spriteVRange + sprite.v0
|
||||
|
||||
// Glow vs non-glow is the effect's own environmentLighting flag: non-environment-lit particles
|
||||
// are full-bright (they read as "glowing", and under packs that relight particles by the
|
||||
// lightmap — Bliss/Photon — full-bright keeps them bright), while environment-lit particles use
|
||||
// world light and read as lit by their surroundings. This preserves the authored distinction
|
||||
// under shaders; per-pack blend correctness is handled in ParticleMaterials.
|
||||
val p = if (storm.effect.particle.environmentLighting) getLightColor(tickDelta) else (15 shl 20 or (15 shl 4))
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -20,6 +20,13 @@ object CobblemonShaders {
|
||||
lateinit var PARTICLE_BLEND: ShaderInstance
|
||||
// This is Material.ALPHA. Weird internal name for "alphatest" shader.
|
||||
lateinit var PARTICLE_CUTOUT: ShaderInstance
|
||||
// Fullscreen screen-space colored "dynamic light" for glowing moves (see MoveGlowRenderer).
|
||||
lateinit var MOVE_GLOW_LIGHT: ShaderInstance
|
||||
// Composites the move-glow accumulation buffer onto the scene (additive).
|
||||
lateinit var MOVE_GLOW_COMPOSITE: ShaderInstance
|
||||
|
||||
/** Whether the move-glow shaders have finished loading (they load with the other core shaders). */
|
||||
fun isMoveGlowLightReady(): Boolean = ::MOVE_GLOW_LIGHT.isInitialized && ::MOVE_GLOW_COMPOSITE.isInitialized
|
||||
|
||||
private fun registerShader(shader: (ResourceProvider) -> ShaderRegistryData, callback: Consumer<ShaderInstance>){
|
||||
SHADERS_TO_REGISTER.add(Pair(shader, callback))
|
||||
@@ -31,5 +38,11 @@ object CobblemonShaders {
|
||||
registerShader({rm: ResourceProvider -> ShaderRegistryData(rm, cobblemonResource("particle_cutout"), DefaultVertexFormat.PARTICLE) }) {
|
||||
PARTICLE_CUTOUT = it
|
||||
}
|
||||
registerShader({rm: ResourceProvider -> ShaderRegistryData(rm, cobblemonResource("move_glow_light"), DefaultVertexFormat.POSITION) }) {
|
||||
MOVE_GLOW_LIGHT = it
|
||||
}
|
||||
registerShader({rm: ResourceProvider -> ShaderRegistryData(rm, cobblemonResource("move_glow_composite"), DefaultVertexFormat.POSITION) }) {
|
||||
MOVE_GLOW_COMPOSITE = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,15 @@ data class RenderEvent(
|
||||
|
||||
/** Represents when a layer is rendered by [LevelRenderer.renderLevel]. Ordinal corresponds with its render order. */
|
||||
enum class Stage {
|
||||
TRANSLUCENT
|
||||
TRANSLUCENT,
|
||||
|
||||
/**
|
||||
* After [LevelRenderer.renderLevel] has finished — on a shaderpack this is after Iris/OptiFine
|
||||
* has composited the world and restored vanilla shaders, so draws here are NOT intercepted by
|
||||
* the pack and render with vanilla GL while still depth-testing against the full world depth.
|
||||
* Used to draw the colored move-glow ([com.cobblemon.mod.common.client.render.MoveGlowRenderer])
|
||||
* so it tints the scene in the move's color identically under every pack.
|
||||
*/
|
||||
POST_LEVEL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#version 150
|
||||
|
||||
// Composites the move-glow accumulation buffer onto the scene. The buffer was filled with a MAX blend
|
||||
// so overlapping move lights kept the brightest contribution (they don't sum), and this adds that
|
||||
// result to the scene once. Alpha is forced to 0 so the additive composite doesn't touch scene alpha.
|
||||
|
||||
uniform sampler2D GlowSampler;
|
||||
|
||||
in vec2 texCoord;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
fragColor = vec4(texture(GlowSampler, texCoord).rgb, 0.0);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"blend": {
|
||||
"func": "add",
|
||||
"srcrgb": "one",
|
||||
"dstrgb": "one",
|
||||
"srcalpha": "one",
|
||||
"dstalpha": "one"
|
||||
},
|
||||
"vertex": "cobblemon:move_glow_light",
|
||||
"fragment": "cobblemon:move_glow_composite",
|
||||
"attributes": [
|
||||
"Position"
|
||||
],
|
||||
"samplers": [
|
||||
{"name": "GlowSampler"}
|
||||
],
|
||||
"uniforms": []
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#version 150
|
||||
|
||||
// Screen-space colored "dynamic light" for glowing Cobblemon moves. Runs as a fullscreen additive
|
||||
// pass AFTER the world (and shaderpack) has composited, reading the scene depth to find each surface's
|
||||
// position and adding the move's color with distance falloff. This tints the actual blocks/entities
|
||||
// near the move in its color (no visible shape), complementing LambDynamicLights' monochrome level.
|
||||
|
||||
uniform sampler2D DepthSampler; // main framebuffer depth (full world depth at this stage)
|
||||
uniform mat4 InverseProjMat; // inverse of the world projection, to rebuild view-space position
|
||||
uniform vec3 LightViewPosA; // light segment start, in view space (== B for a point light)
|
||||
uniform vec3 LightViewPosB; // light segment end, in view space (beams: emitter->target)
|
||||
uniform vec3 LightColor; // the move's color (0..1)
|
||||
uniform float LightRadius; // falloff radius around the segment, in blocks
|
||||
uniform float Intensity; // overall strength (0..1)
|
||||
|
||||
in vec2 texCoord;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
float depth = texture(DepthSampler, texCoord).r;
|
||||
|
||||
// Don't light the sky / far plane.
|
||||
if (depth >= 1.0) {
|
||||
fragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruct the surface position in view space from depth.
|
||||
vec4 ndc = vec4(texCoord * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
||||
vec4 viewPos = InverseProjMat * ndc;
|
||||
viewPos.xyz /= viewPos.w;
|
||||
|
||||
// Distance to the nearest point on the light segment A->B (capsule light). For a point light
|
||||
// A == B, so this reduces to distance-to-point. For a beam it lights all around the whole line.
|
||||
vec3 ab = LightViewPosB - LightViewPosA;
|
||||
float t = clamp(dot(viewPos.xyz - LightViewPosA, ab) / max(dot(ab, ab), 1e-5), 0.0, 1.0);
|
||||
vec3 closest = LightViewPosA + ab * t;
|
||||
float dist = distance(viewPos.xyz, closest);
|
||||
|
||||
float atten = clamp(1.0 - dist / LightRadius, 0.0, 1.0);
|
||||
atten *= atten; // quadratic falloff reads more like real light
|
||||
|
||||
fragColor = vec4(LightColor * atten * Intensity, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"blend": {
|
||||
"func": "add",
|
||||
"srcrgb": "one",
|
||||
"dstrgb": "one",
|
||||
"srcalpha": "one",
|
||||
"dstalpha": "one"
|
||||
},
|
||||
"vertex": "cobblemon:move_glow_light",
|
||||
"fragment": "cobblemon:move_glow_light",
|
||||
"attributes": [
|
||||
"Position"
|
||||
],
|
||||
"samplers": [
|
||||
{"name": "DepthSampler"}
|
||||
],
|
||||
"uniforms": [
|
||||
{"name": "InverseProjMat", "type": "matrix4x4", "count": 16, "values": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]},
|
||||
{"name": "LightViewPosA", "type": "float", "count": 3, "values": [0.0, 0.0, 0.0]},
|
||||
{"name": "LightViewPosB", "type": "float", "count": 3, "values": [0.0, 0.0, 0.0]},
|
||||
{"name": "LightColor", "type": "float", "count": 3, "values": [1.0, 1.0, 1.0]},
|
||||
{"name": "LightRadius", "type": "float", "count": 1, "values": [8.0]},
|
||||
{"name": "Intensity", "type": "float", "count": 1, "values": [1.0]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#version 150
|
||||
|
||||
// Fullscreen pass: Position is supplied directly in NDC (-1..1), no transform needed.
|
||||
in vec3 Position;
|
||||
|
||||
out vec2 texCoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(Position.xy, 0.0, 1.0);
|
||||
texCoord = Position.xy * 0.5 + 0.5;
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import java.net.URI
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force(libs.fabric.loader)
|
||||
@@ -38,6 +40,7 @@ repositories {
|
||||
maven("https://oss.sonatype.org/content/repositories/snapshots")
|
||||
maven(url = "https://api.modrinth.com/maven")
|
||||
maven(url = "https://maven.terraformersmc.com/")
|
||||
maven(url = "https://maven.gegy.dev") // LambDynamicLights + spruceui/pridelib (dev runtime)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -48,6 +51,34 @@ 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")
|
||||
|
||||
// Dynamic lighting (DEV RUNTIME ONLY). Cobblemon compiles against the LambDynamicLights API
|
||||
// (modCompileOnly, mojmap — see common/build.gradle.kts); this provides the running mod in dev.
|
||||
// Pinned to 4.1.6 to match that API AND fabric-loader 0.17.2 (4.8.9 needs loader >=0.18.4). Loom
|
||||
// does NOT surface a mod's JIJ'd dependency mods to the dev run, so its four bundled deps are
|
||||
// listed explicitly (from gegy + Maven Central). Bump all five together if LDL is updated.
|
||||
modLocalRuntime("maven.modrinth:lambdynamiclights:4.1.6+1.21.1")
|
||||
modLocalRuntime("dev.lambdaurora.lambdynamiclights:lambdynamiclights-api:4.1.6+1.21.1")
|
||||
modLocalRuntime("dev.lambdaurora:spruceui:6.2.1+1.21")
|
||||
modLocalRuntime("dev.yumi.mc.core:yumi-mc-foundation:1.0.0-alpha.7+1.21.1")
|
||||
modLocalRuntime("io.github.queerbric:pridelib:1.2.1+1.21")
|
||||
|
||||
modImplementation(libs.fabric.loader)
|
||||
modApi(libs.fabric.api)
|
||||
modApi(libs.bundles.fabric)
|
||||
@@ -110,6 +141,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,21 @@ class CobblemonFabricClient: ClientModInitializer, CobblemonClientImplementation
|
||||
)
|
||||
}
|
||||
|
||||
// Fires after the level (and any shaderpack composite) — the only point a draw isn't
|
||||
// intercepted by Iris/OptiFine. Used by MoveGlowRenderer for the colored move glow.
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user