HitImpact
Table of Contents
1. About
HitImpact spawns textured particles when a surface is hit.
Features:
- works for hitscan and projectile attacks,
- works for all games and mods (in theory),
- works even when loaded with old saves.
Known issues:
- Performance. HitImpact tries to minimize the amount of particles created when a lot of hits happen in a short period of time, but performance drop is still inevitable.
- Silliness. Basically, an impact particle has a version of a surface texture, and for some textures this may look silly.
- Accuracy. Don't expect physical simulation level of precision. It's approximations and compromises salad.
There is room for improvement, help appreciated!
2. License
SPDX-FileCopyrightText: © 2026 Alexander Kromm <mmaulwurff@gmail.com> SPDX-License-Identifier: GPL-3.0-only
3. Source
GameInfo { EventHandlers = "hi_EventHandler" }
version 4.14.3
class hi_EventHandler : StaticEventHandler { override void worldThingDestroyed(WorldEvent event) { int found = mMissiles.find(event.thing); if (found != mMissiles.size()) mMissiles[found] = NULL; } override void worldTick() { int missileCount = mMissiles.size(); for (int i = 0; i < missileCount; ++i) { Actor missile = mMissiles[i]; if (!missile || !missile.target || missile.vel.x != 0 || missile.vel.y != 0 || missile.vel.z != 0) continue; ts_HitData hitData; getMissileHitData(missile.target, missile, hitData); if (hitData.isHit) spawnDebris(hitData, INTENSITY_MISSILE); mMissiles[i] = NULL; } // Clean up. if ((level.time % TICRATE) == 0 && missileCount > 0) { mNewMissiles.clear(); foreach (missile : mMissiles) if (missile) mNewMissiles.push(missile); mMissiles.move(mNewMissiles); } mAmountFactor = max(0, mAmountFactor - 0.5); } private Array<Actor> mMissiles; private Array<Actor> mNewMissiles; private double mAmountFactor; override void worldThingSpawned(WorldEvent event) { Actor thing = event.thing; if (thing) { if (thing.bMissile && thing.damage) mMissiles.push(thing); else if (thing.bIsPuff && thing.damageSource) { ts_HitData hitData; getPuffHitData(thing.damageSource, thing, hitData); if (hitData.isHit) spawnDebris(hitData, INTENSITY_PUFF); } } } override void onRegister() { mParticleParameters.color1 = ""; mParticleParameters.style = STYLE_Normal; mParticleParameters.flags = SPF_FaceCamera | SPF_Roll; mParticleParameters.startAlpha = START_ALPHA; mParticleParameters.accel = (0, 0, -0.15); for (int i = 0; i < CANVAS_COUNT; ++i) { string canvasName = string.format("hi_texture%d", i); mCanvases[i] = TexMan.getCanvas(canvasName); mCanvasTextures[i] = TexMan.checkForTexture(canvasName); } } private FSpawnParticleParams mParticleParameters; const TRACE_FLAGS = TRF_NoSky; const START_ALPHA = 1.0; const INTENSITY_PUFF = 1.0; const INTENSITY_MISSILE = 3.0; private static void getPuffHitData(Actor source, Actor puff, out ts_HitData result) { result.isHit = false; FLineTraceData traceData; double angle = source.angleTo(puff); double offsetZ = getShootOffsetZ(source); double pitch = source.pitchTo(puff, offsetZ); bool isHit = source.lineTrace(angle, 999, pitch, TRACE_FLAGS, offsetZ, data: traceData); if (!isHit || traceData.hitType == FLineTraceData.TRACE_HitNone) return; if (!traceData.hitTexture) return; vector3 normal = getNormalFromTrace(traceData); if (normal.x == 0 && normal.y == 0 && normal.z == 0) return; Color color = getColorFromTrace(traceData); result.isHit = true; result.normal = normal; result.incidence = traceData.hitDir; result.location = traceData.hitLocation; result.texture = traceData.hitTexture; result.color = color; } private static void getMissileHitData(Actor source, Actor missile, out ts_HitData result) { result.isHit = false; if (missile.blockingMobj) return; vector3 normal; TextureId texture; Color aColor; if (missile.blockingFloor) { normal = missile.blockingFloor.floorPlane.normal; texture = missile.blockingFloor.getTexture(Sector.floor); aColor = missile.blockingFloor.specialColors[Sector.floor]; } else if (missile.blockingCeiling) { normal = missile.blockingCeiling.ceilingPlane.normal; texture = missile.blockingCeiling.getTexture(Sector.ceiling); aColor = missile.blockingCeiling.specialColors[Sector.ceiling]; } else if (missile.blockingLine) { normal = getNormalFromLine(missile.blockingLine, Line.front); // Walls have bottom, middle and top textures, trace to know where we hit. FLineTraceData traceData; Line line = missile.blockingLine; vector2 lineCenter = (line.v1.p + line.v2.p) / 2; double offsetZ = missile.height / 2; vector2 dir = lineCenter - missile.pos.xy; double angle = atan2(dir.y, dir.x); double distance = dir.length() + 1; bool isHit = missile.lineTrace(angle, distance, 0, TRACE_FLAGS, offsetZ, data: traceData); // Not sure why, trace sometimes misses when near the line center. // In that case, use a fallback. Hopefully it's wrong not too often. texture = (isHit && traceData.hitType != FLineTraceData.TRACE_HitNone) ? traceData.hitTexture : line.sidedef[Line.front].getTexture(Side.mid); aColor = Color(""); } else // Assuming 3D floor. { // Trace will miss the 3D floor if a missile just brushes against it. FLineTraceData traceData; double offsetZ = missile.height / 2; double angle = missile.angle; double distance = missile.radius * 2; bool isHit = missile.lineTrace(angle, distance, 0, TRACE_FLAGS, offsetZ, data: traceData); if (isHit && (traceData.hitType == FLineTraceData.TRACE_HitFloor || traceData.hitType == FLineTraceData.TRACE_HitCeiling) && traceData.hit3dFloor) { normal = (traceData.hitType == FLineTraceData.TRACE_HitFloor) ? traceData.hit3dFloor.top.normal : traceData.hit3dFloor.bottom.normal; texture = traceData.hitTexture; aColor = (traceData.hitType == FLineTraceData.TRACE_HitFloor) ? traceData.hit3dFloor.model.specialColors[Sector.floor] : traceData.hit3dFloor.model.specialColors[Sector.ceiling]; } else return; } if (normal.x == 0 && normal.y == 0 && normal.z == 0) return; if (!texture) return; // This isn't accurate if missile source changed Z position since firing or // missile position isn't straight. vector2 incidenceXy = Actor.rotateVector((1, 0), missile.angle); vector3 difference = level.vec3Diff(source.pos, missile.pos).unit(); vector3 incidence = (incidenceXy.x, incidenceXy.y, difference.z).unit(); result.isHit = true; result.normal = normal; result.incidence = incidence; result.location = missile.pos; result.texture = texture; result.color = aColor; } private void spawnDebris(ts_HitData data, double intensity) { vector3 normal = data.normal; vector3 incidence = data.incidence; vector3 reflected = incidence - 2 * normal * (normal dot incidence); int amount = max(1, int(10 * intensity / (mAmountFactor + 1))); mParticleParameters.lifetime = int(TICRATE * intensity); mParticleParameters.fadeStep = START_ALPHA / mParticleParameters.lifetime; mParticleParameters.pos = data.location; mParticleParameters.color1 = data.color; let [textureWidth, textureHeight] = TexMan.getSize(data.texture); int minDrawTextureX = CANVAS_SIZE - textureWidth; int minDrawTextureY = CANVAS_SIZE - textureHeight; for (int i = 0; i < amount; ++i) { mCanvasIndex = (mCanvasIndex + 1) % CANVAS_COUNT; mCanvases[mCanvasIndex].drawTexture(data.texture, false, random[hi](minDrawTextureX, 0), random[hi](minDrawTextureY, 0)); mParticleParameters.texture = mCanvasTextures[mCanvasIndex]; mParticleParameters.size = frandom[hi](1, 4 * intensity); mParticleParameters.vel = reflected * 2 * intensity + (frandom[hi](-intensity, intensity), frandom[hi](-intensity, intensity), frandom[hi](-intensity, intensity)); mParticleParameters.startRoll = frandom[hi](0, 360); mParticleParameters.rollVel = frandom[hi](30, 60); level.spawnParticle(mParticleParameters); } if (amount > 1) mAmountFactor += 3; } private int mCanvasIndex; private Canvas mCanvases[CANVAS_COUNT]; private TextureId mCanvasTextures[CANVAS_COUNT]; const CANVAS_COUNT = 512; const CANVAS_SIZE = 12; private static vector3 getNormalFromTrace(FLineTraceData data) { switch (data.hitType) { case FLineTraceData.TRACE_HitFloor: return data.hit3dFloor ? data.hit3dFloor.top.normal : data.hitSector.floorPlane.normal; case FLineTraceData.TRACE_HitCeiling: return data.hit3dFloor ? data.hit3dFloor.bottom.normal : data.hitSector.ceilingPlane.normal; case FLineTraceData.TRACE_HitWall: return getNormalFromLine(data.hitLine, data.lineSide); } return (0, 0, 0); } private static color getColorFromTrace(FLineTraceData data) { switch (data.hitType) { case FLineTraceData.TRACE_HitFloor: return data.hit3dFloor ? data.hit3dFloor.model.specialColors[Sector.floor] : data.hitSector.specialColors[Sector.floor]; case FLineTraceData.TRACE_HitCeiling: return data.hit3dFloor ? data.hit3dFloor.model.specialColors[Sector.ceiling] : data.hitSector.specialColors[Sector.ceiling]; } return Color(""); } private static vector3 getNormalFromLine(Line line, int side) { vector2 diff = (side == Line.front) ? line.v2.p - line.v1.p : line.v1.p - line.v2.p; return (diff.y, -diff.x, 0).unit(); } private static double getShootOffsetZ(Actor actor) { let pawn = (PlayerPawn)(actor); return actor.height * 0.5 + (pawn ? pawn.attackZOffset * pawn.player.crouchFactor - pawn.floorclip : 0); } }
struct ts_HitData { bool isHit; vector3 normal; vector3 incidence; vector3 location; TextureId texture; Color color; }
Animdefs:
(setq hi-canvas-count 512) (if (string-equal print "true") hi-canvas-count "")
(mapconcat (lambda (x) (format "CanvasTexture hi_texture%d 12 12\n" x)) (number-sequence 0 (- hi-canvas-count 1)))
4. Tests
Check that the add-on at least can be loaded, and both hitscan and projectile attacks don't cause critical issues.
wait 1; map map01 wait 10; give all wait 20; +attack wait 30; -attack wait 40; use RocketLauncher wait 60; +attack wait 80; -attack wait 110; quit