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

GPL-3.0-only

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

Created: 2026-05-27 Wed 02:53